Compare commits

...

108 Commits

Author SHA1 Message Date
Peter Steinberger
be4435e7cf fix: track connection time for watchdog (fixes #99) 2026-01-03 19:55:45 +00:00
Peter Steinberger
424d31af1f docs(templates): add voice storytelling tip for sag users 2026-01-03 19:55:32 +00:00
Peter Steinberger
e9d7ac8e84 feat(gateway): add config hot reload 2026-01-03 19:52:24 +00:00
Peter Steinberger
fac694fc03 docs(skills): add parallel Codex orchestration learnings
- coding-agent: document --yolo flag, git worktree + tmux pattern
- tmux: add section on orchestrating coding agents in parallel

Learnings from running 5 parallel Codex sessions to analyze GitHub issues
2026-01-03 19:45:18 +00:00
Shadow
3e84b9632d Discord: handle system message types 2026-01-03 13:15:19 -06:00
Peter Steinberger
ce3fd09e14 docs(faq): add alternative providers section (OpenRouter, Z.AI)
- Added OpenRouter and Z.AI setup examples
- Emphasized using latest Claude 4.5 models, not deprecated 3.x

🦞
2026-01-03 19:14:05 +00:00
Peter Steinberger
641080a0b6 fix: document macOS permission requirements 2026-01-03 20:05:22 +01:00
Jake
99c3fc1128 Scripts: Make ad-hoc fallback opt-in with stronger TCC warnings 2026-01-03 20:05:22 +01:00
Jake
8c7b2aa2d3 Scripts: Fallback to ad-hoc signing in codesign-mac-app.sh 2026-01-03 20:05:22 +01:00
Peter Steinberger
55a07a0ef0 style: fix lint formatting 2026-01-03 18:51:25 +00:00
Peter Steinberger
9899ba53a3 Docs: add PR number for Discord reactions 2026-01-03 18:48:36 +00:00
Peter Steinberger
52458a5628 Discord: default reaction notifications to own 2026-01-03 18:48:36 +00:00
Shadow
7abd6713c8 Docs: clarify discord reaction notifications 2026-01-03 18:48:36 +00:00
Shadow
451174ca10 Discord: add reaction notification allowlist 2026-01-03 18:48:36 +00:00
Peter Steinberger
cdfbd6e7eb test(gateway): align config constants in auth test 2026-01-03 19:37:09 +01:00
Peter Steinberger
350e007a5c test(agents): extend text_end coverage 2026-01-03 19:37:09 +01:00
Peter Steinberger
5e156135a1 test(gateway): avoid hoisted export errors 2026-01-03 19:37:09 +01:00
Peter Steinberger
b7ec9ae475 fix(gateway): format status/code errors 2026-01-03 19:37:09 +01:00
Peter Steinberger
8a18af409d test(gateway): cover helper registries 2026-01-03 19:37:09 +01:00
Peter Steinberger
6a125b554b refactor(gateway): split server helpers 2026-01-03 19:37:09 +01:00
Shadow
ce92fac983 chore: formatting 2026-01-03 12:35:16 -06:00
Peter Steinberger
341a224301 docs: credit Hyaxia in changelog and credits
Co-authored-by: Maxim Vovshin <36747317+Hyaxia@users.noreply.github.com>
2026-01-03 18:05:46 +00:00
Peter Steinberger
95cd153f33 feat: add blogwatcher skill 2026-01-03 18:00:08 +00:00
Peter Steinberger
0af89022ff fix: avoid Swift compiler crash in onboarding wizard 2026-01-03 17:59:37 +00:00
Peter Steinberger
27a8f3d061 chore: add inline guidance for block streaming 2026-01-03 18:46:59 +01:00
Peter Steinberger
72b34f7d03 fix: harden block stream dedupe 2026-01-03 18:44:07 +01:00
Peter Steinberger
73fa2e10bc refactor: split gateway server methods 2026-01-03 18:14:07 +01:00
Peter Steinberger
4a6b33d799 refactor: add gateway server helper modules 2026-01-03 18:00:45 +01:00
Peter Steinberger
145964c85e feat: add github skill 2026-01-03 17:57:13 +01:00
Peter Steinberger
217b84f2ac fix: drop final payloads after block streaming 2026-01-03 17:55:31 +01:00
Peter Steinberger
1d6de24ab3 feat: configurable control ui base path 2026-01-03 17:55:31 +01:00
Peter Steinberger
822def84d2 docs(faq): add Tailscale bind conflict + model/thinking compatibility
- Added Tailscale serve requires bind: loopback (not lan)
- Added model + thinking mode issues section (Gemini Flash, Opus, local LLMs)

From Discord #help session learnings 🦞
2026-01-03 16:53:56 +00:00
Peter Steinberger
f313af75e9 fix: avoid duplicate block-stream payloads 2026-01-03 16:53:56 +00:00
Peter Steinberger
591773715e fix: honor whatsapp per-group mention overrides 2026-01-03 17:51:10 +01:00
Peter Steinberger
dd6b9b510b docs: update changelog for gateway refactor 2026-01-03 17:35:29 +01:00
Peter Steinberger
6ae51ae3de refactor: split gateway server helpers and tests 2026-01-03 17:34:52 +01:00
Peter Steinberger
00c3e98431 docs: add tmux skill guidance 2026-01-03 17:31:26 +01:00
Peter Steinberger
dd561f58d1 docs: expand coding-agent Pi usage 2026-01-03 17:21:17 +01:00
Peter Steinberger
200dd634fb fix: preserve block streaming order 2026-01-03 17:14:01 +01:00
Peter Steinberger
3bbdcaf87f fix: avoid duplicate block streaming 2026-01-03 17:10:47 +01:00
Peter Steinberger
abff5e3b1f docs: thank @ratulsarna for control UI UUID fallback 2026-01-03 15:56:36 +00:00
Peter Steinberger
40ee0f0672 build: lock x86_64 relay to AVX2 2026-01-03 16:52:06 +01:00
Peter Steinberger
9f8eeceae7 feat: soften block streaming chunking 2026-01-03 16:48:26 +01:00
Peter Steinberger
53baba71fa feat: unify onboarding + config schema 2026-01-03 16:48:08 +01:00
Peter Steinberger
0f85080d81 Merge pull request #133 from ratulsarna/fix/ui-http-uuid
fix(ui): robust UUID generation for HTTP Control UI
2026-01-03 16:16:43 +01:00
Peter Steinberger
72f8148080 fix: clean up embedded lint 2026-01-03 15:09:07 +00:00
Peter Steinberger
be3da5b856 fix: update protocol models and android parsing 2026-01-03 15:04:24 +00:00
Peter Steinberger
9a9b429f74 fix: elevate embedded run logs to info 2026-01-03 15:03:03 +00:00
Peter Steinberger
733e86516e fix: address runtime warnings in build 2026-01-03 15:01:38 +00:00
Peter Steinberger
1a00175eb7 chore: fix lint formatting 2026-01-03 14:57:49 +00:00
Peter Steinberger
77c76ca52f test: fix transcription and tool schema assertions 2026-01-03 14:55:05 +00:00
Peter Steinberger
5de3395204 fix: resolve gcloud python path 2026-01-03 14:36:48 +00:00
Peter Steinberger
4e4655f607 docs(faq): use correct codex login --device-auth command 2026-01-03 14:13:18 +00:00
Peter Steinberger
48731f494b fix: add embedded run logs and typing ttl 2026-01-03 14:09:19 +00:00
Peter Steinberger
4fcd89c3d9 docs(faq): add stop/cancel task + Codex subscription auth sections
- Added FAQ for /stop and other abort commands
- Added FAQ explaining Codex CLI browser auth vs API key
- Browser OAuth uses ChatGPT Pro subscription, API key is pay-per-token

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-03 14:08:24 +00:00
Peter Steinberger
a4f433a1b1 docs: update onboarding steps 2026-01-03 14:08:24 +00:00
Ratul Sarna
84a7ee491b fix(ui): robust UUID generation on HTTP
Fixes #131
2026-01-03 13:43:20 +00:00
Peter Steinberger
3043dd3a0c fix: restructure macOS connections settings 2026-01-03 14:25:03 +01:00
Jake
81f4a7cdb7 Agents: Fix Gemini schema compatibility and robust model discovery 2026-01-03 13:57:29 +01:00
Peter Steinberger
c2a74d6d2a docs(template): add 'Write It Down' rule to AGENTS.md template
Mental notes don't survive sessions. Files do. Text > Brain 📝
2026-01-03 12:52:11 +00:00
Peter Steinberger
861e1b33f5 docs(skill): add PR review safety rules for coding-agent
- Never checkout branches in live Clawdis repo
- Clone to temp folder or use git worktree for reviews
- Added explicit examples for safe PR review workflow
2026-01-03 12:49:03 +00:00
Peter Steinberger
0647d56555 fix(build): repair tool-meta regex literal 2026-01-03 12:46:33 +00:00
Peter Steinberger
ea6aea8532 docs: warn about gmail watcher port conflict 2026-01-03 12:41:44 +00:00
Peter Steinberger
6eca2edd79 chore(swift): update Swabble package lock 2026-01-03 13:38:18 +01:00
Peter Steinberger
d31dfbc565 chore(canvas): refresh a2ui bundle hash 2026-01-03 13:38:12 +01:00
Peter Steinberger
1e0f776824 test(gateway): add multi-instance e2e suite 2026-01-03 13:37:46 +01:00
Peter Steinberger
db36f0105d fix(gateway): validate event/response frames 2026-01-03 13:37:40 +01:00
Peter Steinberger
5377e2400a fix: avoid red gmail-watcher prefix 2026-01-03 12:36:15 +00:00
Peter Steinberger
72c0aa63fb style: tidy imports and formatting 2026-01-03 12:35:23 +00:00
Peter Steinberger
933bee220f fix(cron): pass resolved channel to agent tools 2026-01-03 12:35:23 +00:00
Peter Steinberger
bd2dabfa8f fix(agents): load tool display config from disk 2026-01-03 12:35:23 +00:00
Peter Steinberger
f11b352089 fix(macos): expand onboarding window height 2026-01-03 13:34:30 +01:00
Peter Steinberger
bb54e60179 fix(logging): decouple file logs from console verbose 2026-01-03 12:32:14 +00:00
Peter Steinberger
e52bdaa2a2 fix: repair tool meta regex 2026-01-03 12:30:46 +00:00
Peter Steinberger
b6301c719b fix: default low thinking for reasoning models 2026-01-03 12:19:06 +00:00
Peter Steinberger
6e16c0699a feat: centralize tool display metadata 2026-01-03 13:18:27 +01:00
Peter Steinberger
bf4ad295af docs(faq): add media/vision troubleshooting section
- Added FAQ entry for images/media not being understood
- Covers vision-capable models checklist
- Debugging steps for media pipeline
- Link to summarize.sh for exotic files

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-03 11:43:40 +00:00
Peter Steinberger
7a80e8fe77 refactor: centralize home path shortening 2026-01-03 12:42:27 +01:00
Peter Steinberger
1ec3512925 refactor!: drop clawdis_ tool prefix 2026-01-03 12:39:52 +01:00
Peter Steinberger
772ada4308 fix: refine tool summaries and scope discord tool 2026-01-03 12:33:42 +01:00
Peter Steinberger
7165c8a7e5 refactor: rename bundle identifiers to com.clawdis 2026-01-03 12:26:22 +01:00
Peter Steinberger
daa1460502 docs(discord): document sendMessage mediaUrl and to format
- Add example for sendMessage with media attachment (file:// and https://)
- Clarify that sendMessage uses 'to: channel:<id>' not 'channelId'
- Document replyTo parameter for replying to specific messages
- Add mediaUrl to inputs section
2026-01-03 11:05:09 +00:00
Peter Steinberger
f47c7ac369 feat: support configurable gateway port 2026-01-03 12:00:17 +01:00
Peter Steinberger
7199813969 docs: document gateway port configuration 2026-01-03 11:46:58 +01:00
Peter Steinberger
87d5fa516d docs(skills): correct bear-notes instructions
Co-authored-by: Tyler Wince <tylerwince@users.noreply.github.com>
2026-01-03 11:34:31 +01:00
Claude
10340d2a3f feat(skills): add bear-notes skill using grizzly CLI 2026-01-03 11:29:14 +01:00
Peter Steinberger
508c4d362f docs: update changelog for gog skill 2026-01-03 11:20:17 +01:00
Mariano Belinky
f73b008251 docs: add Sheets/Docs examples to gog skill 2026-01-03 11:20:17 +01:00
Peter Steinberger
c583e64bb7 chore: update changelog 2026-01-03 11:17:00 +01:00
Peter Steinberger
9df63b008d docs: move telegram chunking fix to beta6 2026-01-03 11:15:57 +01:00
Peter Steinberger
3daecc092c docs: add changelog entry for telegram block replies 2026-01-03 11:12:15 +01:00
Muhammed Mukhthar CM
4d42811ecf fix(telegram): add textLimit to block reply chunking
Block streaming replies were missing the textLimit parameter in
deliverReplies(), causing long messages to fail with 'message is too
long' error instead of being chunked properly.

The final reply path already included textLimit, but the onBlockReply
callback path did not.
2026-01-03 11:12:15 +01:00
Peter Steinberger
1bebcf8033 chore: update appcast and TUI streaming handling 2026-01-03 11:06:49 +01:00
Peter Steinberger
45c555a4bd fix: use x86_64 bun for relay builds 2026-01-03 11:06:49 +01:00
Peter Steinberger
5986a83e80 fix: skip duplicate arch merge for Sparkle 2026-01-03 11:06:49 +01:00
Peter Steinberger
732de4acf0 fix: make Sparkle builds numeric + universal 2026-01-03 11:06:48 +01:00
Shadow
7400c0946e Discord: update UIs to use the new config 2026-01-03 01:02:22 -06:00
Peter Steinberger
14ee2b2c11 FAQ: Add common questions from Discord (Jan 3)
- Linux/VPS installation without Homebrew
- Minimum system requirements (runs on 1GB RAM!)
- Enterprise OAuth status (not supported yet)
- Discord DM allowlist config
- Model switching with /model
- Message queue modes with /queue
2026-01-03 06:09:51 +00:00
Peter Steinberger
c3e1b8cfd9 chore: update protocol swift models 2026-01-03 06:44:21 +01:00
Peter Steinberger
67a67df35a fix: avoid unsafe string coercion in tui 2026-01-03 06:44:17 +01:00
Peter Steinberger
0f0578b268 docs: check off tui gate 2026-01-03 06:37:44 +01:00
Peter Steinberger
662208949f fix: align sessions.patch and tui typing 2026-01-03 06:37:40 +01:00
Peter Steinberger
e41821342b docs: refresh tui guide 2026-01-03 06:28:36 +01:00
Peter Steinberger
d3458a4fc3 feat: overhaul tui controller 2026-01-03 06:27:38 +01:00
Peter Steinberger
32c91bbb25 feat: add tui ui kit 2026-01-03 06:22:20 +01:00
Peter Steinberger
aee13507f9 feat: expand tui gateway client 2026-01-03 06:17:33 +01:00
Peter Steinberger
61b67f6301 feat: extend gateway session patch 2026-01-03 06:16:49 +01:00
Peter Steinberger
b86619bcd0 docs: fix appcast to only ship beta5 2026-01-03 06:12:01 +01:00
360 changed files with 23872 additions and 12361 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ vendor/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
.bundle.hash
# fastlane (iOS)
apps/ios/fastlane/README.md

View File

@@ -1,5 +1,55 @@
# Changelog
## Unreleased
### Breaking
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
### Fixes
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
- Agent tools: scope the Discord tool to Discord surface runs.
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
- macOS onboarding: increase window height so the permissions page fits without scrolling.
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
- Build: fix regex literal in tool-meta path detection (watch build error).
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Dependencies: bump pi-mono packages to 0.32.3.
### Docs
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Skills: add tmux skill + interactive coding guidance in coding-agent.
- Gateway: document port configuration + multi-instance isolation.
- Gateway: document config hot reload + reload matrix.
- Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
## 2.0.0-beta5 — 2026-01-03
### Fixed
@@ -49,7 +99,8 @@
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
- CLI: add `clawdis tui` gateway-connected terminal UI (local or remote).
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
- Skills: allow `bun` as a node manager for skill installs.
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
@@ -59,6 +110,7 @@
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
### Fixes
- macOS codesign: make ad-hoc signing opt-in with loud warnings and document TCC permission fragility — thanks @mcinteerj.
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
@@ -210,7 +262,7 @@
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
- Gateway in-process restart: `clawdis_gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
- Gateway in-process restart: `gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
### Breaking
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.

View File

@@ -109,7 +109,7 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
### iOS app (apps/ios)
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.

View File

@@ -1,6 +1,15 @@
{
"originHash" : "2012d083159d375d07febbc184c592c569d7ab48247045e35a762e3269d4cadc",
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"pins" : [
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",

View File

@@ -2,20 +2,11 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<item>
<title>2.0.0-beta4</title>
<pubDate>Sat, 03 Jan 2026 06:04:02 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta4</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
</item>
<item>
<title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 06:04:02 +0100</pubDate>
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta5</sparkle:version>
<sparkle:version>2765</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
@@ -71,7 +62,8 @@
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
<li>CLI: add <code>clawdis tui</code> gateway-connected terminal UI (local or remote).</li>
<li>CLI: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
@@ -214,7 +206,7 @@
</ul>
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="72078343" type="application/octet-stream" sparkle:edSignature="xSfMHcYYEktlE+EC8cnM7GJP0oqjfN4EK+oIIcTHvXtgXG7LcBqo4kMQEVrr1kIzCIqQ+7/R6HMd/rWtfkc3Cg=="/>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
</item>
</channel>
</rss>

View File

@@ -6,7 +6,7 @@ plugins {
}
android {
namespace = "com.steipete.clawdis.node"
namespace = "com.clawdis.android"
compileSdk = 36
sourceSets {
@@ -16,7 +16,7 @@ android {
}
defaultConfig {
applicationId = "com.steipete.clawdis.node"
applicationId = "com.clawdis.android"
minSdk = 31
targetSdk = 36
versionCode = 1

View File

@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
enum class CameraHudKind {
Photo,

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.content.Context
import android.os.Build

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.Manifest
import android.content.pm.ApplicationInfo
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.steipete.clawdis.node.ui.RootScreen
import com.steipete.clawdis.node.ui.ClawdisTheme
import com.clawdis.android.ui.RootScreen
import com.clawdis.android.ui.ClawdisTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {

View File

@@ -1,12 +1,12 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.chat.OutgoingAttachment
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager
import com.clawdis.android.bridge.BridgeEndpoint
import com.clawdis.android.chat.OutgoingAttachment
import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.app.Application

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.app.Notification
import android.app.NotificationChannel
@@ -146,7 +146,7 @@ class NodeForegroundService : Service() {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
private const val ACTION_STOP = "com.clawdis.android.action.STOP"
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.Manifest
import android.content.Context
@@ -6,27 +6,27 @@ import android.content.pm.PackageManager
import android.os.Build
import android.os.SystemClock
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.chat.ChatController
import com.steipete.clawdis.node.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatPendingToolCall
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.steipete.clawdis.node.chat.OutgoingAttachment
import com.steipete.clawdis.node.bridge.BridgeDiscovery
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient
import com.steipete.clawdis.node.bridge.BridgeSession
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.BuildConfig
import com.steipete.clawdis.node.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager
import com.steipete.clawdis.node.protocol.ClawdisCapability
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
import com.steipete.clawdis.node.voice.TalkModeManager
import com.steipete.clawdis.node.voice.VoiceWakeManager
import com.clawdis.android.chat.ChatController
import com.clawdis.android.chat.ChatMessage
import com.clawdis.android.chat.ChatPendingToolCall
import com.clawdis.android.chat.ChatSessionEntry
import com.clawdis.android.chat.OutgoingAttachment
import com.clawdis.android.bridge.BridgeDiscovery
import com.clawdis.android.bridge.BridgeEndpoint
import com.clawdis.android.bridge.BridgePairingClient
import com.clawdis.android.bridge.BridgeSession
import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.BuildConfig
import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.protocol.ClawdisCapability
import com.clawdis.android.protocol.ClawdisCameraCommand
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
import com.clawdis.android.protocol.ClawdisCanvasCommand
import com.clawdis.android.protocol.ClawdisScreenCommand
import com.clawdis.android.voice.TalkModeManager
import com.clawdis.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.content.pm.PackageManager
import android.content.Intent

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import android.app.Activity
import android.content.Context

View File

@@ -1,6 +1,6 @@
@file:Suppress("DEPRECATION")
package com.steipete.clawdis.node
package com.clawdis.android
import android.content.Context
import androidx.core.content.edit

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
enum class VoiceWakeMode(val rawValue: String) {
Off("off"),

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
object WakeWords {
const val maxWords: Int = 32

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
object BonjourEscapes {
fun decode(input: String): String {

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import android.content.Context
import android.net.ConnectivityManager

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
data class BridgeEndpoint(
val stableId: String,

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -11,7 +11,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import com.steipete.clawdis.node.BuildConfig
import com.clawdis.android.BuildConfig
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject

View File

@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.chat
package com.clawdis.android.chat
import com.steipete.clawdis.node.bridge.BridgeSession
import com.clawdis.android.bridge.BridgeSession
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
@@ -361,10 +361,12 @@ class ChatController(
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
if (phase == "start") {
val args = data?.get("args").asObjectOrNull()
pendingToolCallsById[toolCallId] =
ChatPendingToolCall(
toolCallId = toolCallId,
name = name,
args = args,
startedAtMs = ts,
isError = null,
)

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.chat
package com.clawdis.android.chat
data class ChatMessage(
val id: String,
@@ -18,6 +18,7 @@ data class ChatMessageContent(
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
val args: kotlinx.serialization.json.JsonObject? = null,
val startedAtMs: Long,
val isError: Boolean? = null,
)

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node
package com.clawdis.android.node
import android.Manifest
import android.content.Context
@@ -20,7 +20,7 @@ import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale
import com.steipete.clawdis.node.PermissionRequester
import com.clawdis.android.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node
package com.clawdis.android.node
import android.graphics.Bitmap
import android.graphics.Canvas
@@ -17,7 +17,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import com.steipete.clawdis.node.BuildConfig
import com.clawdis.android.BuildConfig
import kotlin.coroutines.resume
class CanvasController {

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node
package com.clawdis.android.node
import kotlin.math.max
import kotlin.math.min

View File

@@ -1,11 +1,11 @@
package com.steipete.clawdis.node.node
package com.clawdis.android.node
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager
import android.util.Base64
import com.steipete.clawdis.node.ScreenCaptureRequester
import com.clawdis.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -16,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
data class Payload(val payloadJson: String)
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null
@Volatile private var permissionRequester: com.clawdis.android.PermissionRequester? = null
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
screenCaptureRequester = requester
}
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) {
fun attachPermissionRequester(requester: com.clawdis.android.PermissionRequester) {
permissionRequester = requester
}

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol
package com.clawdis.android.protocol
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol
package com.clawdis.android.protocol
enum class ClawdisCapability(val rawValue: String) {
Canvas("canvas"),

View File

@@ -0,0 +1,222 @@
package com.clawdis.android.tools
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
@Serializable
private data class ToolDisplayActionSpec(
val label: String? = null,
val detailKeys: List<String>? = null,
)
@Serializable
private data class ToolDisplaySpec(
val emoji: String? = null,
val title: String? = null,
val label: String? = null,
val detailKeys: List<String>? = null,
val actions: Map<String, ToolDisplayActionSpec>? = null,
)
@Serializable
private data class ToolDisplayConfig(
val version: Int? = null,
val fallback: ToolDisplaySpec? = null,
val tools: Map<String, ToolDisplaySpec>? = null,
)
data class ToolDisplaySummary(
val name: String,
val emoji: String,
val title: String,
val label: String,
val verb: String?,
val detail: String?,
) {
val detailLine: String?
get() {
val parts = mutableListOf<String>()
if (!verb.isNullOrBlank()) parts.add(verb)
if (!detail.isNullOrBlank()) parts.add(detail)
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
val summaryLine: String
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
}
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
private val json = Json { ignoreUnknownKeys = true }
@Volatile private var cachedConfig: ToolDisplayConfig? = null
fun resolve(
context: Context,
name: String?,
args: JsonObject?,
meta: String? = null,
): ToolDisplaySummary {
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
val key = trimmedName.lowercase()
val config = loadConfig(context)
val spec = config.tools?.get(key)
val fallback = config.fallback
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
val title = spec?.title ?: titleFromName(trimmedName)
val label = spec?.label ?: trimmedName
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
val action = actionRaw?.takeIf { it.isNotEmpty() }
val actionSpec = action?.let { spec?.actions?.get(it) }
val verb = normalizeVerb(actionSpec?.label ?: action)
var detail: String? = null
if (key == "read") {
detail = readDetail(args)
} else if (key == "write" || key == "edit" || key == "attach") {
detail = pathDetail(args)
}
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
if (detail == null) {
detail = firstValue(args, detailKeys)
}
if (detail == null) {
detail = meta
}
if (detail != null) {
detail = shortenHomeInString(detail)
}
return ToolDisplaySummary(
name = trimmedName,
emoji = emoji,
title = title,
label = label,
verb = verb,
detail = detail,
)
}
private fun loadConfig(context: Context): ToolDisplayConfig {
val existing = cachedConfig
if (existing != null) return existing
return try {
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
cachedConfig = decoded
decoded
} catch (_: Throwable) {
val fallback = ToolDisplayConfig()
cachedConfig = fallback
fallback
}
}
private fun titleFromName(name: String): String {
val cleaned = name.replace("_", " ").trim()
if (cleaned.isEmpty()) return "Tool"
return cleaned
.split(Regex("\\s+"))
.joinToString(" ") { part ->
val upper = part.uppercase()
if (part.length <= 2 && part == upper) part
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
}
private fun normalizeVerb(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
return trimmed.replace("_", " ")
}
private fun readDetail(args: JsonObject?): String? {
val path = args?.get("path")?.asStringOrNull() ?: return null
val offset = args["offset"].asNumberOrNull()
val limit = args["limit"].asNumberOrNull()
return if (offset != null && limit != null) {
val end = offset + limit
"${path}:${offset.toInt()}-${end.toInt()}"
} else {
path
}
}
private fun pathDetail(args: JsonObject?): String? {
return args?.get("path")?.asStringOrNull()
}
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
for (key in keys) {
val value = valueForPath(args, key)
val rendered = renderValue(value)
if (!rendered.isNullOrBlank()) return rendered
}
return null
}
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
var current: JsonElement? = args
for (segment in path.split(".")) {
if (segment.isBlank()) return null
val obj = current as? JsonObject ?: return null
current = obj[segment]
}
return current
}
private fun renderValue(value: JsonElement?): String? {
if (value == null) return null
if (value is JsonPrimitive) {
if (value.isString) {
val trimmed = value.contentOrNull?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
if (firstLine.isEmpty()) return null
return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine
}
val raw = value.contentOrNull?.trim().orEmpty()
raw.toBooleanStrictOrNull()?.let { return it.toString() }
raw.toLongOrNull()?.let { return it.toString() }
raw.toDoubleOrNull()?.let { return it.toString() }
}
if (value is JsonArray) {
val items = value.mapNotNull { renderValue(it) }
if (items.isEmpty()) return null
val preview = items.take(3).joinToString(", ")
return if (items.size > 3) "${preview}…" else preview
}
return null
}
private fun shortenHomeInString(value: String): String {
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
if (home.isNullOrEmpty()) return value
return value.replace(home, "~")
.replace(Regex("/Users/[^/]+"), "~")
.replace(Regex("/home/[^/]+"), "~")
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
}
private fun JsonElement?.asNumberOrNull(): Double? {
val primitive = this as? JsonPrimitive ?: return null
val raw = primitive.contentOrNull ?: return null
return raw.toDoubleOrNull()
}
}

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View File

@@ -1,8 +1,8 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import androidx.compose.runtime.Composable
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
import com.clawdis.android.MainViewModel
import com.clawdis.android.ui.chat.ChatSheetContent
@Composable
fun ChatSheet(viewModel: MainViewModel) {

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import android.annotation.SuppressLint
import android.Manifest
@@ -65,8 +65,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.CameraHudKind
import com.steipete.clawdis.node.MainViewModel
import com.clawdis.android.CameraHudKind
import com.clawdis.android.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import android.Manifest
import android.content.pm.PackageManager
@@ -47,10 +47,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.BuildConfig
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.NodeForegroundService
import com.steipete.clawdis.node.VoiceWakeMode
import com.clawdis.android.BuildConfig
import com.clawdis.android.MainViewModel
import com.clawdis.android.NodeForegroundService
import com.clawdis.android.VoiceWakeMode
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
@@ -381,7 +381,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
val parsed = com.clawdis.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui
package com.clawdis.android.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -38,7 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.clawdis.android.chat.ChatSessionEntry
@Composable
fun ChatComposer(

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatPendingToolCall
import com.clawdis.android.chat.ChatMessage
import com.clawdis.android.chat.ChatPendingToolCall
@Composable
fun ChatMessageListCard(

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
@@ -31,11 +31,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import com.steipete.clawdis.node.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatMessageContent
import com.steipete.clawdis.node.chat.ChatPendingToolCall
import com.clawdis.android.chat.ChatMessage
import com.clawdis.android.chat.ChatMessageContent
import com.clawdis.android.chat.ChatPendingToolCall
import com.clawdis.android.tools.ToolDisplayRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext
@Composable
fun ChatMessageBubble(message: ChatMessage) {
@@ -104,18 +106,42 @@ fun ChatTypingIndicatorBubble() {
@Composable
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
val context = LocalContext.current
val displays =
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (t in toolCalls.take(6)) {
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
}
if (toolCalls.size > 6) {
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.clawdis.android.chat.ChatSessionEntry
@Composable
fun ChatSessionsDialog(

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import android.content.ContentResolver
import android.net.Uri
@@ -19,8 +19,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.chat.OutgoingAttachment
import com.clawdis.android.MainViewModel
import com.clawdis.android.chat.OutgoingAttachment
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

View File

@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.clawdis.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
import android.media.MediaDataSource
import kotlin.math.min

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
import android.Manifest
import android.content.Context
@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.bridge.BridgeSession
import com.clawdis.android.bridge.BridgeSession
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
object VoiceWakeCommandExtractor {
fun extractCommand(text: String, triggerWords: List<String>): String? {

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
import android.content.Context
import android.content.Intent

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node
package com.clawdis.android
import org.junit.Assert.assertEquals
import org.junit.Test

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import org.junit.Assert.assertEquals
import org.junit.Test

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge
package com.clawdis.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node
package com.clawdis.android.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node
package com.clawdis.android.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol
package com.clawdis.android.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol
package com.clawdis.android.protocol
import org.junit.Assert.assertEquals
import org.junit.Test

View File

@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.ui.chat
package com.clawdis.android.ui.chat
import com.steipete.clawdis.node.chat.ChatSessionEntry
import com.clawdis.android.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Test

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull

View File

@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice
package com.clawdis.android.voice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull

View File

@@ -14,7 +14,7 @@ actor BridgeClient {
{
self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
let queue = DispatchQueue(label: "com.clawdis.ios.bridge-client")
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue)

View File

@@ -99,7 +99,7 @@ final class BridgeConnectionController {
guard !instanceId.isEmpty else { return }
let token = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
service: "com.clawdis.bridge",
account: self.keychainAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return }
@@ -189,7 +189,7 @@ final class BridgeConnectionController {
if !refreshed.isEmpty, refreshed != token {
_ = KeychainStore.saveString(
refreshed,
service: "com.steipete.clawdis.bridge",
service: "com.clawdis.bridge",
account: self.keychainAccount(instanceId: instanceId))
}
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))

View File

@@ -102,7 +102,7 @@ final class BridgeDiscoveryModel {
}
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery.\(domain)"))
browser.start(queue: DispatchQueue(label: "com.clawdis.ios.bridge-discovery.\(domain)"))
}
}

View File

@@ -78,7 +78,7 @@ actor BridgeSession {
let params = NWParameters.tcp
params.includePeerToPeer = true
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-session")
let queue = DispatchQueue(label: "com.clawdis.ios.bridge-session")
self.connection = connection
self.queue = queue

View File

@@ -1,8 +1,8 @@
import Foundation
enum BridgeSettingsStore {
private static let bridgeService = "com.steipete.clawdis.bridge"
private static let nodeService = "com.steipete.clawdis.node"
private static let bridgeService = "com.clawdis.bridge"
private static let nodeService = "com.clawdis.node"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"

View File

@@ -68,7 +68,7 @@ final class ScreenRecordService: @unchecked Sendable {
try? FileManager.default.removeItem(at: outURL)
let state = CaptureState()
let recordQueue = DispatchQueue(label: "com.steipete.clawdis.screenrecord")
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in

View File

@@ -353,7 +353,7 @@ struct SettingsTab: View {
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
service: "com.clawdis.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
@@ -381,7 +381,7 @@ struct SettingsTab: View {
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.steipete.clawdis.bridge",
service: "com.clawdis.bridge",
account: self.keychainAccount())
}
@@ -427,7 +427,7 @@ struct SettingsTab: View {
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
service: "com.clawdis.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
@@ -455,7 +455,7 @@ struct SettingsTab: View {
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.steipete.clawdis.bridge",
service: "com.clawdis.bridge",
account: self.keychainAccount())
}

View File

@@ -47,7 +47,7 @@ final class TalkModeManager: NSObject {
private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "TalkMode")
private let logger = Logger(subsystem: "com.clawdis", category: "TalkMode")
func attachBridge(_ bridge: BridgeSession) {
self.bridge = bridge

View File

@@ -6,7 +6,7 @@ import Testing
@Suite struct BridgeClientTests {
private final class LineServer: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.steipete.clawdis.tests.bridge-client-server")
private let queue = DispatchQueue(label: "com.clawdis.tests.bridge-client-server")
private let listener: NWListener
private var connection: NWConnection?
private var buffer = Data()

View File

@@ -10,8 +10,8 @@ private struct KeychainEntry: Hashable {
let account: String
}
private let bridgeService = "com.steipete.clawdis.bridge"
private let nodeService = "com.steipete.clawdis.node"
private let bridgeService = "com.clawdis.bridge"
private let nodeService = "com.clawdis.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")

View File

@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
let account: String
}
private let bridgeService = "com.steipete.clawdis.bridge"
private let nodeService = "com.steipete.clawdis.node"
private let bridgeService = "com.clawdis.bridge"
private let nodeService = "com.clawdis.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")

View File

@@ -4,7 +4,7 @@ import Testing
@Suite struct KeychainStoreTests {
@Test func saveLoadUpdateDeleteRoundTrip() {
let service = "com.steipete.clawdis.tests.\(UUID().uuidString)"
let service = "com.clawdis.tests.\(UUID().uuidString)"
let account = "value"
#expect(KeychainStore.delete(service: service, account: account))

View File

@@ -1,4 +1,4 @@
app_identifier("com.steipete.clawdis.ios")
app_identifier("com.clawdis.ios")
# Auth is expected via App Store Connect API key.
# Provide either:

View File

@@ -1,6 +1,6 @@
name: Clawdis
options:
bundleIdPrefix: com.steipete.clawdis
bundleIdPrefix: com.clawdis
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
@@ -65,8 +65,8 @@ targets:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: Y5PE65HELJ
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
PROVISIONING_PROFILE_SPECIFIER: "com.steipete.clawdis.ios Development"
PRODUCT_BUNDLE_IDENTIFIER: com.clawdis.ios
PROVISIONING_PROFILE_SPECIFIER: "com.clawdis.ios Development"
SWIFT_VERSION: "6.0"
info:
path: Sources/Info.plist
@@ -98,7 +98,7 @@ targets:
product: SwabbleKit
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests
PRODUCT_BUNDLE_IDENTIFIER: com.clawdis.ios.tests
SWIFT_VERSION: "6.0"
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdis.app/Clawdis"
BUNDLE_LOADER: "$(TEST_HOST)"

View File

@@ -2,7 +2,7 @@ import Foundation
import OSLog
enum AgentWorkspace {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
private static let logger = Logger(subsystem: "com.clawdis", category: "workspace")
static let agentsFilename = "AGENTS.md"
static let soulFilename = "SOUL.md"
static let identityFilename = "IDENTITY.md"

View File

@@ -58,7 +58,7 @@ enum AnthropicAuthResolver {
}
enum AnthropicOAuth {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
private static let logger = Logger(subsystem: "com.clawdis", category: "anthropic-oauth")
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!

View File

@@ -19,7 +19,7 @@ actor BridgeConnectionHandler {
private let logger: Logger
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.steipete.clawdis.bridge.connection")
private let queue = DispatchQueue(label: "com.clawdis.bridge.connection")
private var buffer = Data()
private var isAuthenticated = false

View File

@@ -8,7 +8,7 @@ import OSLog
actor BridgeServer {
static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "bridge")
private let logger = Logger(subsystem: "com.clawdis", category: "bridge")
private var listener: NWListener?
private var isRunning = false
private var store: PairedNodesStore?
@@ -42,7 +42,7 @@ actor BridgeServer {
Task { await self.handleListenerState(state) }
}
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.bridge"))
listener.start(queue: DispatchQueue(label: "com.clawdis.bridge"))
self.listener = listener
} catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")

View File

@@ -36,7 +36,7 @@ actor CameraCaptureService {
}
}
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "camera")
private let logger = Logger(subsystem: "com.clawdis", category: "camera")
func listDevices() -> [CameraDeviceInfo] {
Self.availableCameras().map { device in

View File

@@ -10,7 +10,7 @@ final class CanvasFileWatcher: @unchecked Sendable {
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.queue = DispatchQueue(label: "com.steipete.clawdis.canvaswatcher")
self.queue = DispatchQueue(label: "com.clawdis.canvaswatcher")
self.onChange = onChange
}

View File

@@ -7,7 +7,7 @@ import OSLog
final class CanvasManager {
static let shared = CanvasManager()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasManager")
private static let logger = Logger(subsystem: "com.clawdis", category: "CanvasManager")
private var panelController: CanvasWindowController?
private var panelSessionKey: String?

View File

@@ -3,7 +3,7 @@ import Foundation
import OSLog
import WebKit
private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
private let canvasLogger = Logger(subsystem: "com.clawdis", category: "Canvas")
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL

View File

@@ -1,6 +1,6 @@
import AppKit
let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
let canvasWindowLogger = Logger(subsystem: "com.clawdis", category: "Canvas")
enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680)

View File

@@ -1,7 +1,7 @@
import Foundation
enum ClawdisConfigFile {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "config")
private static let logger = Logger(subsystem: "com.clawdis", category: "config")
static func url() -> URL {
ClawdisPaths.configURL
@@ -106,4 +106,20 @@ enum ClawdisConfigFile {
return remote["password"] as? String
}
static func gatewayPort() -> Int? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any] else { return nil }
if let port = gateway["port"] as? Int, port > 0 { return port }
if let number = gateway["port"] as? NSNumber, number.intValue > 0 {
return number.intValue
}
if let raw = gateway["port"] as? String,
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
return parsed
}
return nil
}
}

View File

@@ -5,7 +5,7 @@ import OSLog
final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection")
private let logger = Logger(subsystem: "com.clawdis", category: "connection")
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.

File diff suppressed because it is too large Load Diff

View File

@@ -144,6 +144,45 @@ struct ConfigSnapshot: Codable {
let issues: [Issue]?
}
struct DiscordGuildChannelForm: Identifiable {
let id = UUID()
var key: String
var allow: Bool
var requireMention: Bool
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
self.key = key
self.allow = allow
self.requireMention = requireMention
}
}
struct DiscordGuildForm: Identifiable {
let id = UUID()
var key: String
var slug: String
var requireMention: Bool
var reactionNotifications: String
var users: String
var channels: [DiscordGuildChannelForm]
init(
key: String = "",
slug: String = "",
requireMention: Bool = false,
reactionNotifications: String = "own",
users: String = "",
channels: [DiscordGuildChannelForm] = []
) {
self.key = key
self.slug = slug
self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users
self.channels = channels
}
}
@MainActor
@Observable
final class ConnectionsStore {
@@ -169,11 +208,15 @@ final class ConnectionsStore {
var telegramBusy = false
var discordEnabled = true
var discordToken: String = ""
var discordDmEnabled = true
var discordAllowFrom: String = ""
var discordGroupEnabled = false
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordTextChunkLimit: String = ""
var discordReplyToMode: String = "off"
var discordGuilds: [DiscordGuildForm] = []
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
@@ -401,6 +444,7 @@ final class ConnectionsStore {
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
let discordDm = discord?["dm"]?.dictionaryValue
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
if let allow = discordDm?["allowFrom"]?.arrayValue {
let strings = allow.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
@@ -434,6 +478,61 @@ final class ConnectionsStore {
} else {
self.discordHistoryLimit = ""
}
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
self.discordTextChunkLimit = String(Int(limit))
} else {
self.discordTextChunkLimit = ""
}
if let mode = discord?["replyToMode"]?.stringValue, ["off", "first", "all"].contains(mode) {
self.discordReplyToMode = mode
} else {
self.discordReplyToMode = "off"
}
if let guilds = discord?["guilds"]?.dictionaryValue {
self.discordGuilds = guilds
.map { key, value in
let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw
: "own"
let users = entry["users"]?.arrayValue?
.compactMap { item -> String? in
if let str = item.stringValue { return str }
if let intVal = item.intValue { return String(intVal) }
if let doubleVal = item.doubleValue { return String(Int(doubleVal)) }
return nil
}
.joined(separator: ", ") ?? ""
let channels: [DiscordGuildChannelForm]
if let channelMap = entry["channels"]?.dictionaryValue {
channels = channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention =
channelEntry["requireMention"]?.boolValue ?? false
return DiscordGuildChannelForm(
key: channelKey,
allow: allow,
requireMention: channelRequireMention)
}
} else {
channels = []
}
return DiscordGuildForm(
key: key,
slug: slug,
requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users,
channels: channels)
}
.sorted { $0.key < $1.key }
} else {
self.discordGuilds = []
}
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
@@ -625,6 +724,11 @@ final class ConnectionsStore {
}
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
let allow = self.discordAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -673,6 +777,56 @@ final class ConnectionsStore {
discord.removeValue(forKey: "historyLimit")
}
let chunkLimit = self.discordTextChunkLimit.trimmingCharacters(in: .whitespacesAndNewlines)
if chunkLimit.isEmpty {
discord.removeValue(forKey: "textChunkLimit")
} else if let value = Int(chunkLimit), value > 0 {
discord["textChunkLimit"] = value
} else {
discord.removeValue(forKey: "textChunkLimit")
}
let replyToMode = self.discordReplyToMode.trimmingCharacters(in: .whitespacesAndNewlines)
if replyToMode.isEmpty || replyToMode == "off" {
discord.removeValue(forKey: "replyToMode")
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
} else {
discord.removeValue(forKey: "replyToMode")
}
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = entry.users
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = channel.key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
}
if guilds.isEmpty {
discord.removeValue(forKey: "guilds")
} else {
discord["guilds"] = guilds
}
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
if value == defaultValue {

View File

@@ -1,7 +1,7 @@
import Foundation
let launchdLabel = "com.steipete.clawdis"
let gatewayLaunchdLabel = "com.steipete.clawdis.gateway"
let launchdLabel = "com.clawdis.mac"
let gatewayLaunchdLabel = "com.clawdis.gateway"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 7
let pauseDefaultsKey = "clawdis.pauseEnabled"

View File

@@ -73,7 +73,7 @@ final class ControlChannel {
}
private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let logger = Logger(subsystem: "com.clawdis", category: "control")
private var eventTask: Task<Void, Never>?
private var recoveryTask: Task<Void, Never>?
@@ -185,7 +185,7 @@ final class ControlChannel {
"Reason: \(reason)"
}
// Common misfire: we connected to localhost:18789 but the port is occupied
// Common misfire: we connected to the configured localhost port but it is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
// The gateway handshake returns something we can't parse, which currently
// surfaces as "hello failed (unexpected response)". Give the user a pointer

View File

@@ -21,7 +21,7 @@ final class CronJobsStore {
var lastError: String?
var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private let logger = Logger(subsystem: "com.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>?
private var runsTask: Task<Void, Never>?
private var eventTask: Task<Void, Never>?

View File

@@ -306,7 +306,7 @@ struct DebugSettings: View {
}
if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18789 and suggest fixes.")
Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
@@ -946,7 +946,7 @@ extension DebugSettings {
view.portCheckInFlight = true
view.portReports = [
DebugActions.PortReport(
port: 18789,
port: GatewayEnvironment.gatewayPort(),
expected: "Gateway websocket (node/tsx)",
status: .missing("Missing"),
listeners: []),

View File

@@ -4,7 +4,7 @@ import Foundation
import OSLog
import Security
private let deepLinkLogger = Logger(subsystem: "com.steipete.clawdis", category: "DeepLink")
private let deepLinkLogger = Logger(subsystem: "com.clawdis", category: "DeepLink")
@MainActor
final class DeepLinkHandler {

View File

@@ -6,7 +6,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
static let shared = DockIconManager()
private var windowsObservation: NSKeyValueObservation?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "DockIconManager")
private let logger = Logger(subsystem: "com.clawdis", category: "DockIconManager")
override private init() {
super.init()

View File

@@ -58,7 +58,7 @@ struct WebSocketSessionBox: @unchecked Sendable {
private typealias ProtoAnyCodable = ClawdisProtocol.AnyCodable
actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
private let logger = Logger(subsystem: "com.clawdis", category: "gateway")
private var task: WebSocketTaskBox?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
private var connected = false

View File

@@ -3,7 +3,7 @@ import ClawdisProtocol
import Foundation
import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.connection")
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdis", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
@@ -51,6 +51,10 @@ actor GatewayConnection {
case providersStatus = "providers.status"
case configGet = "config.get"
case configSet = "config.set"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
case wizardStatus = "wizard.status"
case talkMode = "talk.mode"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"

View File

@@ -34,7 +34,7 @@ final class GatewayDiscoveryModel {
private var localIdentity: LocalIdentity
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-discovery")
private let logger = Logger(subsystem: "com.clawdis", category: "gateway-discovery")
init() {
self.localIdentity = Self.buildLocalIdentityFast()
@@ -69,7 +69,7 @@ final class GatewayDiscoveryModel {
}
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.gateway-discovery.\(domain)"))
browser.start(queue: DispatchQueue(label: "com.clawdis.macos.gateway-discovery.\(domain)"))
}
}

Some files were not shown because too many files have changed in this diff Show More