Compare commits

..
Author SHA1 Message Date
Peter Steinberger c01bfcbc12 fix: load CLI plugin registry for channel-aware commands (#1338) (thanks @MaudeBot) 2026-01-21 00:48:36 +00:00
Maude Bot 47110e88c7 fix(cli): load plugin registry for message/channels commands
Fixes #1327 - 'clawdbot message --channel telegram' fails with
'Unknown channel: telegram' because plugins weren't loaded.

The Commander code path (non-route-first) calls ensureConfigReady() in
preAction but doesn't load the plugin registry. Channel plugins like
telegram are registered during plugin loading, so getChannelPlugin()
returns undefined without it.

This adds ensurePluginRegistryLoaded() call for commands that need
channel plugin access: message, channels, directory.
2026-01-20 23:59:43 +00:00
Peter Steinberger 51dfd6efdb fix: tighten small-model audit guardrails 2026-01-20 23:52:26 +00:00
Peter Steinberger 4fad74738a fix: prefer loopback for auto bind fallback 2026-01-20 23:48:26 +00:00
Peter Steinberger 69f0469530 Merge pull request #1332 from clawdbot/temp/landpr-model-catalog-improvements
fix(model-catalog): improve cache resilience
2026-01-20 23:07:51 +00:00
Peter Steinberger eb1ee36f59 fix: relax diagnostic event typing (#1332) (thanks @steipete) 2026-01-20 23:07:28 +00:00
Peter Steinberger b341512564 fix: model catalog cache + TUI editor ctor (#1326) (thanks @dougvk) 2026-01-20 22:58:41 +00:00
Peter Steinberger 6734f2d71c fix: wire OTLP logs for diagnostics 2026-01-20 22:51:47 +00:00
Peter Steinberger e12abf3114 fix: update CustomEditor constructor 2026-01-20 22:36:06 +00:00
Peter Steinberger 2dfd3b9a81 chore: drop nostr node_modules links 2026-01-20 20:15:56 +00:00
Peter Steinbergerandjoelklabo 7b6cbf5869 feat: add Nostr channel plugin and onboarding install defaults
Co-authored-by: joelklabo <joelklabo@users.noreply.github.com>
2026-01-20 20:15:56 +00:00
Peter Steinberger 8686b3b951 Merge pull request #1326 from dougvk/fix/model-catalog-cache-poison
fix(model-catalog): avoid caching import failures
2026-01-20 20:14:52 +00:00
Peter Steinberger 2e7e135bc0 fix: config form semantics + editor ctor (#1315) (thanks @MaudeBot) 2026-01-20 20:14:22 +00:00
Peter Steinberger c287664923 Merge pull request #1315 from MaudeCode/feat/config-ui-sections
feat(ui): config page overhaul with sidebar nav, search, and improved fields
2026-01-20 20:12:42 +00:00
Peter Steinberger 18f0051d26 fix: avoid discord gateway abort crash 2026-01-20 19:33:08 +00:00
Peter Steinberger b012b1105e fix: unblock discord listener concurrency 2026-01-20 19:30:32 +00:00
Peter SteinbergerandLarus Ivarsson 21370fc09b fix: allow fallback on timeout aborts
Co-authored-by: Larus Ivarsson <larusivar@gmail.com>
2026-01-20 19:23:13 +00:00
Peter Steinberger 4999f15688 refactor: consolidate mac debug CLI 2026-01-20 19:17:31 +00:00
Doug von Kohorn e4f9555f21 fix(model-catalog): avoid caching import failures
Move dynamic import of @mariozechner/pi-coding-agent into the try/catch so transient module resolution errors don't poison the model catalog cache with a rejected promise.

This previously caused Discord/Telegram handlers and heartbeat to fail until process restart if the import failed once.
2026-01-20 20:09:55 +01:00
Peter Steinberger 243a8b019e fix: route native status to active agent 2026-01-20 19:04:31 +00:00
Peter Steinberger 5c4079f66c feat: add diagnostics events and otel exporter 2026-01-20 18:56:15 +00:00
Peter Steinberger b1f086b536 chore(changelog): note cron auto-delivery (#1285) 2026-01-20 18:53:08 +00:00
Peter Steinberger d298b8c16b fix(cron): auto-deliver agent output to explicit targets 2026-01-20 17:56:15 +00:00
Peter Steinberger 40968bd5e0 test: stabilize atomic reindex search mock 2026-01-20 17:50:42 +00:00
Peter Steinberger 80e6c070bf refactor: centralize discord api errors 2026-01-20 17:28:19 +00:00
Peter Steinberger 26fcca087b fix(macos): resolve AnyCodable alias conflicts 2026-01-20 17:27:45 +00:00
Peter Steinbergerandgnarco 02ca148583 fix: preserve subagent thread routing (#1241)
Thanks @gnarco.

Co-authored-by: gnarco <gnarco@users.noreply.github.com>
2026-01-20 17:22:07 +00:00
Peter Steinberger ae1c6f4313 docs: update changelog for gateway auth errors 2026-01-20 17:12:26 +00:00
Peter Steinberger 9faed2226a fix: soften discord resolve warnings 2026-01-20 17:11:52 +00:00
Peter Steinberger cf04b24632 fix: clarify gateway auth unauthorized message 2026-01-20 17:06:02 +00:00
Peter Steinberger 9f856abfe7 fix: align tui editor with pi-tui API 2026-01-20 16:51:44 +00:00
Maude Bot e74fd9196c feat(ui): add icons for all config sections
Added SVG icons for: meta, logging, browser, ui, models, bindings,
broadcast, audio, session, cron, web, discovery, canvasHost, talk, plugins

Also added descriptions for all new sections in metadata.
2026-01-20 11:47:22 -05:00
Peter Steinberger 40e928a4c4 Merge pull request #1271 from Whoaa512/feat/session-picker-mvp
feat: session picker MVP - fuzzy search, derived titles, relative time
2026-01-20 16:46:48 +00:00
Peter Steinberger faa5838147 fix: polish session picker filtering (#1271) (thanks @Whoaa512) 2026-01-20 16:46:15 +00:00
Maude Bot f6abe62e5f feat(ui): major config form UX overhaul
Sidebar:
- SVG icons instead of emoji (consistent rendering)
- Clean navigation with active states

Form fields completely redesigned:
- Toggle rows: full-width clickable with label + description
- Segmented controls: for enum values with ≤5 options
- Number inputs: with +/- stepper buttons
- Text inputs: with reset-to-default button
- Select dropdowns: clean styling with custom arrow
- Arrays: card-based with clear add/remove, item numbering
- Objects: collapsible sections with chevron animation
- Maps: key-value editor with inline editing

Visual improvements:
- Consistent border radius and spacing
- Better color contrast for labels vs help text
- Hover and focus states throughout
- Icons for common actions (add, remove, reset)

Mobile:
- Horizontal scrolling nav on small screens
- Stacked layouts for complex fields
2026-01-20 11:40:13 -05:00
Peter Steinberger 5c5745dee5 fix: auto-enable plugins on startup 2026-01-20 16:38:37 +00:00
Peter Steinberger 15c735de4d chore: update a2ui bundle hash 2026-01-20 16:38:08 +00:00
Peter Steinberger 8bf484bdad fix: update pi-ai/pi-tui usage 2026-01-20 16:38:08 +00:00
CJ Winslow 36719690a2 test: add coverage for readLastMessagePreviewFromTranscript
Also add CHANGELOG entry and perf docs for session list flags.
2026-01-20 16:37:09 +00:00
CJ Winslow f2666d2092 refactor: extract shared fuzzy filter utilities for list components 2026-01-20 16:37:08 +00:00
CJ Winslow a28c271488 TUI: optimize fuzzy filtering and consolidate time formatting
- Extract formatRelativeTime to shared utility for reuse across components
- Optimize FilterableSelectList with pre-lowercased searchTextLower field (avoids toLowerCase on every keystroke)
- Implement custom fuzzy matching with space-separated token support and word boundary scoring
- Use matchesKey utility for consistent keybinding handling (arrows, vim j/k, ctrl+p/n)
- Fix searchable-select-list to support vim keybindings consistently
- Fix system-prompt runtimeInfo null check with nullish coalescing operator
2026-01-20 16:37:08 +00:00
CJ Winslow 1d9d5b30ce feat: add last message preview to session picker
Read the final user/assistant message from session transcripts and display
it in the picker alongside the session update time. Allows quick previews
of what's in each session without opening it.
2026-01-20 16:36:51 +00:00
CJ Winslow 14f56a4e18 TUI: use editor keybindings for select cancel action
Replace hardcoded escape sequence checks with the pi-tui keybindings API to ensure consistent cancel handling across different terminal configurations.
2026-01-20 16:36:51 +00:00
CJ Winslow 687c41e838 TUI: display relative time for session updates in picker
Show "just now", "5m ago", "Yesterday" etc. instead of absolute timestamps
for better readability in the session picker list.
2026-01-20 16:36:51 +00:00
CJ Winslow ddb7b5c6a4 feat: add search param to sessions.list RPC
Server-side filtering backup for client-side session picker search.
Case-insensitive substring match on displayName, label, subject,
sessionId, and key.

Closes #1161
2026-01-20 16:36:51 +00:00
CJ Winslow 262e35c219 refactor: clean up FilterableSelectList per code review
- Remove dead input handlers (onSubmit/onEscape never triggered)
- Store maxVisible as instance property instead of bracket access
- Remove unused filterInput theme property
2026-01-20 16:36:51 +00:00
CJ Winslow 95f0befd65 feat: add fuzzy filter to TUI session picker
Users can now type to filter sessions in real-time:
- FilterableSelectList component wraps pi-tui's fuzzyFilter
- Matches against displayName, label, subject, sessionId
- j/k navigation, Enter selects, Escape clears filter then cancels
- Uses derivedTitle from previous commit for better display

Refs #1161
2026-01-20 16:36:51 +00:00
CJ Winslow 83d5e30027 feat: add heuristic session title derivation for session picker
Enable meaningful session titles via priority-based derivation:
1. displayName (user-set)
2. subject (group name)
3. First user message (truncated to 60 chars)
4. sessionId prefix + date fallback

Opt-in via includeDerivedTitles param to avoid perf impact on
regular listing. Reads only first 10 lines of transcript files.

Closes #1161
2026-01-20 16:36:51 +00:00
Peter Steinberger 842be7b864 chore: bump version to 2026.1.20 2026-01-20 16:36:37 +00:00
Peter Steinberger cb5d76ed3d test: cover beta fallback update logic 2026-01-20 16:36:37 +00:00
Peter Steinberger 3d5ffee07f fix: prefer stable release when beta lags 2026-01-20 16:36:04 +00:00
Maude Bot bd8f4b052d chore: remove duplicate config styles from components.css 2026-01-20 11:29:19 -05:00
Maude Bot 929d50b7d1 feat(ui): complete config page overhaul with sidebar nav, search, toggles, and diff view
Major redesign of the config page:

Layout:
- Sidebar navigation with section list
- Search input to filter settings
- Section cards with icons and descriptions
- Responsive design for mobile (stacked layout)

Fields:
- New toggle switches for booleans (replaces checkboxes)
- Improved field-row layout with label, help text, and control
- Better fieldset and array styling

Features:
- Diff view showing pending changes before save
- Original value tracking for comparison
- Section filtering via sidebar nav
- Search across setting names, descriptions, and nested properties

Styling:
- Dedicated config.css with all new styles
- Dark and light theme support
- Smooth animations and transitions
- Mobile-first responsive breakpoints
2026-01-20 11:28:41 -05:00
Peter Steinberger 4fda10c508 refactor(macos): split exec approvals handler 2026-01-20 16:24:44 +00:00
Peter Steinberger 0b0d8b2406 fix: repair model compat + editor ctor 2026-01-20 16:19:49 +00:00
Peter Steinberger 844ff2ee8f style(macos): fix swiftformat lint 2026-01-20 16:19:37 +00:00
Peter Steinberger 8c666666ef fix(tui): update CustomEditor ctor 2026-01-20 16:06:21 +00:00
Peter Steinberger 2394703593 fix(macos): disambiguate AnyCodable usage 2026-01-20 16:05:08 +00:00
Peter Steinberger 404470853a fix: stabilize gateway tests 2026-01-20 16:02:46 +00:00
Peter Steinberger 99fc0fbac1 feat: sync plugin updates with update channel 2026-01-20 16:00:42 +00:00
Peter Steinberger 91ed00f800 fix: clarify doctor auto-enable hint 2026-01-20 15:58:30 +00:00
Peter SteinbergerandVACInc 76698ed296 fix: allow custom skill config bag
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
2026-01-20 15:57:08 +00:00
Maude Bot 716546824f feat(ui): improve config page with collapsible sections
- Group config settings into logical sections (Core, Agents, Communication, etc.)
- Add collapsible accordion UI for each section group
- Add icons and labels for each config category
- Improve mobile responsiveness with better button layout
- Style improvements for nested fieldsets and arrays
2026-01-20 10:56:44 -05:00
Peter Steinberger 74f382f732 fix: default Anthropic API cache TTL to 1h 2026-01-20 15:48:53 +00:00
Peter Steinberger a76aea1bc0 chore: update a2ui bundle hash 2026-01-20 15:48:52 +00:00
Peter Steinberger 533766207f fix: silence macos warning noise 2026-01-20 15:48:52 +00:00
Peter Steinberger 59fa002561 fix: update device identity signing 2026-01-20 15:48:52 +00:00
Peter Steinberger 48ab168df2 fix: bridge gateway anycodable payloads 2026-01-20 15:48:52 +00:00
Peter Steinberger bef9d5bdc8 chore: refresh swift package resolved 2026-01-20 15:48:52 +00:00
Peter Steinberger c6812c6af4 fix: harden compat and editor ctor 2026-01-20 15:16:05 +00:00
Peter Steinberger 1f7cb4b853 fix: shorten bonjour gateway service type 2026-01-20 15:10:06 +00:00
Peter Steinberger d161f3ab0f docs: refresh development channels timestamp 2026-01-20 15:10:06 +00:00
Peter Steinberger c65b91c841 Merge pull request #1308 from dougvk/fix/preserve-command-arg-casing
fix(session): preserve command argument casing
2026-01-20 15:04:28 +00:00
Peter Steinberger 760b1e8fc6 fix: update model compat + tui editor 2026-01-20 15:02:25 +00:00
Peter Steinberger 188893f319 docs: add WhatsApp family binding example 2026-01-20 15:00:25 +00:00
Peter Steinberger 04ee9e7765 docs: clarify sandbox env + recreate guidance 2026-01-20 15:00:25 +00:00
Peter Steinberger 390ba5f42a fix: guard closeIdleConnections typing 2026-01-20 14:58:31 +00:00
Peter Steinberger b8593fd4fb fix: close idle gateway http connections 2026-01-20 14:56:30 +00:00
Peter Steinberger 68a467dd66 fix: guard ZAI compat on openai-completions api 2026-01-20 14:39:58 +00:00
Peter Steinberger d18319a57d fix: align CustomEditor with pi-tui Editor API 2026-01-20 14:35:42 +00:00
Peter Steinberger 15e5bb3459 feat: improve /new model hints and reset confirmation 2026-01-20 14:35:20 +00:00
Peter Steinberger 41f6d06967 fix: align tui editor init (#1298) (thanks @sibbl) 2026-01-20 14:32:46 +00:00
Peter Steinberger e3a99aa2ce refactor: split matrix provider modules 2026-01-20 14:32:04 +00:00
Peter Steinberger c1d8456860 fix: clean up lint leftovers 2026-01-20 14:25:18 +00:00
Peter Steinberger da10ca1585 fix: drain openresponses test responses 2026-01-20 14:20:04 +00:00
Peter SteinbergerandRichard Poelderl 5d017dae5a feat: add update channel status
Co-authored-by: Richard Poelderl <18185649+p6l-richard@users.noreply.github.com>
2026-01-20 14:19:03 +00:00
Peter Steinberger 30fd7001f2 fix: tolerate pi-tui type exports 2026-01-20 14:17:39 +00:00
Peter SteinbergerandJohn Rood da4b124480 fix(browser): register AI snapshot refs (#1282)
thanks @John-Rood

Co-authored-by: John Rood <62669593+John-Rood@users.noreply.github.com>
2026-01-20 14:14:36 +00:00
John Rood 710c681283 fix(browser): register refs from AI snapshot for act commands
When using the default AI snapshot format without explicit options like
interactive/compact/labels, refs were not being registered because
snapshotAiViaPlaywright returns raw text without ref registration.

This caused 'Unknown ref' errors when subsequently using act commands
with refs like e12 that appeared in the snapshot text.

The fix extracts refs from the AI snapshot using buildRoleSnapshotFromAiSnapshot
and registers them via rememberRoleRefsForTarget so act commands can resolve them.

Fixes #1268
2026-01-20 14:13:48 +00:00
Peter Steinberger e45228ac37 fix: merge login shell PATH for gateway exec 2026-01-20 14:04:13 +00:00
Peter Steinberger a0180f364d fix: accept pi-tui editor ctor variants 2026-01-20 14:02:36 +00:00
Peter Steinberger d69f246ba7 chore: fix lint/format 2026-01-20 13:52:59 +00:00
Peter Steinberger a81989048d fix: update ui ed25519 + bluebubbles actions 2026-01-20 13:43:27 +00:00
Peter Steinberger b56e9964f5 style: format update channel logic 2026-01-20 13:41:30 +00:00
Peter Steinberger ddd7fc1513 style: format update channel logic 2026-01-20 13:41:30 +00:00
Peter Steinberger 4ebf55f1db feat: add dev update channel 2026-01-20 13:41:30 +00:00
Peter SteinbergerandRichard Poelderl cc24ede586 docs: note release channels and add contributor
Co-authored-by: Richard Poelderl <richard.poelderl@gmail.com>
2026-01-20 13:41:30 +00:00
Peter Steinberger eb3b84f3d2 chore: record zalouser PR commits 2026-01-20 13:38:38 +00:00
Peter Steinberger 304244f2be docs: add zalouser PR thanks 2026-01-20 13:33:13 +00:00
Peter Steinberger f067ea25b4 fix: align zalouser status + schema 2026-01-20 13:32:11 +00:00
Peter Steinberger fa51294f65 fix: sync mobile gateway auth v3 2026-01-20 13:30:40 +00:00
Peter Steinberger a4d1c4d522 fix: run doctor config flow once 2026-01-20 13:27:51 +00:00
Peter Steinbergerandthewilloftheshadow 6e17c463ae fix: add /skill fallback for native limits
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-20 13:20:29 +00:00
Peter Steinberger 63797e841d Merge pull request #1247 from sebslight/fix/perplexity-web-search-provider
Config: accept Perplexity for web_search
2026-01-20 13:18:18 +00:00
Peter Steinberger fdb171cb15 refactor: centralize channel ui metadata 2026-01-20 13:11:49 +00:00
Peter Steinberger 6f9861bb9b chore: update deps 2026-01-20 13:06:16 +00:00
Peter Steinberger 759068304e fix: tighten tls fingerprints and approval events 2026-01-20 13:04:20 +00:00
Peter Steinberger ded578b1fa docs: finalize clawnet refactor doc 2026-01-20 13:04:20 +00:00
Peter Steinberger dcb8d16591 fix: validate ws tls fingerprint 2026-01-20 13:04:20 +00:00
Peter Steinberger 06c17a333e docs: update protocol + security notes 2026-01-20 13:04:20 +00:00
Peter Steinberger 409a16060b feat: enrich presence with roles 2026-01-20 13:04:20 +00:00
Peter Steinberger 7720106624 feat: add discovery role hints 2026-01-20 13:04:19 +00:00
Peter Steinberger c613769d22 feat: add remote gateway tls fingerprint 2026-01-20 13:04:19 +00:00
Peter Steinberger 87343c374e feat: route exec approvals via gateway 2026-01-20 13:04:19 +00:00
Peter Steinberger 67be9aed28 chore: make @napi-rs/canvas optional 2026-01-20 13:04:19 +00:00
Peter Steinberger b48d5d96d3 test: cover scope upgrade flow 2026-01-20 13:04:19 +00:00
Peter Steinberger d8cc7db5e6 feat: wire role-scoped device creds 2026-01-20 13:04:19 +00:00
Peter Steinberger dfbf6ac263 feat: enforce device-bound connect challenge 2026-01-20 13:04:19 +00:00
Peter Steinberger 121ae6036b docs: add matrix crypto setup note 2026-01-20 12:42:41 +00:00
Peter Steinberger 6e1ad31b49 build: allow matrix crypto build scripts 2026-01-20 12:23:05 +00:00
Doug von Kohorn b8b0b3f0e7 chore: ignore serena cache 2026-01-20 13:16:49 +01:00
Peter Steinberger 0330b483ad docs: add #1306 changelog entry 2026-01-20 12:08:15 +00:00
Peter Steinberger 9a2bf57e1c refactor: extend channel plugin boundary 2026-01-20 12:07:54 +00:00
Peter Steinberger 439044068a fix: drop stray Peekaboo submodule 2026-01-20 12:07:54 +00:00
Tyler Yust 4c3b4aeb76 fix: remove unused typingSignals variable in get-reply-run 2026-01-20 12:07:54 +00:00
Tyler Yust 1e8b291374 refactor: improve error handling in embedded run payloads by clarifying conditions for user-facing error messages and updating test descriptions for typing behavior 2026-01-20 12:07:54 +00:00
Tyler Yust 95f82154f7 feat: extend BlueBubbles attachment handling by adding support for reply context, allowing users to reference previous messages in media attachments 2026-01-20 12:07:54 +00:00
Tyler Yust 7bc3998451 feat: add media size validation to BlueBubbles media handling, ensuring compliance with channel limits and improving error handling for oversized media 2026-01-20 12:07:54 +00:00
Tyler Yust d029ceab1c feat: enhance BlueBubbles media and message handling by adding reply context support and improving outbound message ID tracking 2026-01-20 12:07:54 +00:00
Tyler Yust c331bdc27d feat: refactor BlueBubbles media handling by introducing a dedicated media send function and optimizing message processing for media attachments 2026-01-20 12:07:54 +00:00
Tyler Yust b0b42b4e14 feat: improve BlueBubbles message processing by adding reply context formatting and enhancing message ID extraction from responses 2026-01-20 12:07:54 +00:00
Tyler Yust e5514d4854 feat: implement reply context handling in BlueBubbles messaging, enhancing message formatting and metadata resolution 2026-01-20 12:07:54 +00:00
Tyler Yust 20bc89d96c feat: enhance BlueBubbles messaging targets by adding support for UUID and hex chat identifiers, improving normalization and parsing functions 2026-01-20 12:07:54 +00:00
Tyler Yust 199fef2a5e feat: enhance BlueBubbles group message handling by adding account-specific logging and improving typing signal conditions 2026-01-20 12:07:54 +00:00
Tyler Yust d9a2ac7e72 feat: enhance BlueBubbles functionality by implementing macOS version checks for message editing and improving server info caching 2026-01-20 12:07:54 +00:00
Tyler Yust a16934b2ab feat: update BlueBubbles documentation and code to clarify group icon handling and normalize chat identifiers 2026-01-20 12:07:54 +00:00
Tyler Yust 14a072f5fa feat: add support for setting group icons in BlueBubbles, enhancing group management capabilities 2026-01-20 12:07:54 +00:00
Tyler Yust 574b848863 feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling 2026-01-20 12:07:54 +00:00
Tyler Yust 2e6c58bf75 feat: improve BlueBubbles message action error handling and enhance channel action descriptions 2026-01-20 12:07:54 +00:00
Tyler Yust a5d89e6eb1 feat: enhance BlueBubbles channel integration with new messaging target normalization and typing indicator improvements 2026-01-20 12:07:54 +00:00
Tyler Yust 61907ddf3e feat: add new channel capabilities for editing, unsending, replying, effects, and group management 2026-01-20 12:07:54 +00:00
Tyler Yust 1eab8fa9b0 Step 5 + Review 2026-01-20 12:07:54 +00:00
Tyler Yust 2cf444be02 Step 4 (Needs Review) 2026-01-20 12:07:54 +00:00
Tyler Yust 7870ce8177 Step 3 + Review 2026-01-20 12:07:54 +00:00
Tyler Yust e9d691d472 (Step 2) Phase 2 & 3 Complete + Reviewed 2026-01-20 12:07:54 +00:00
Tyler Yust ac2fcfe96a Phase 0 + Review 2026-01-20 12:07:54 +00:00
Peter Steinberger 627fa3083b Merge pull request #1298 from sibbl/matrix-with-e2ee-support
rewrite(matrix): integration with end to end encryption support
2026-01-20 12:04:45 +00:00
Peter Steinberger e4877656ca fix: add path import for shell utils (#1298) (thanks @sibbl) 2026-01-20 11:59:36 +00:00
Peter Steinberger d91f0ceeb3 fix: polish matrix e2ee storage (#1298) (thanks @sibbl) 2026-01-20 11:59:36 +00:00
Sebastian Schubotz 9b71382efb rewrite(matrix): use matrix-bot-sdk as base to enable e2ee encryption, strictly follow location + typing + group concepts, fix room bugs 2026-01-20 11:59:11 +00:00
Peter Steinberger dd82d32d85 Merge pull request #1292 from bradleypriest/pr/chat-thinking-tool
ui(chat): separate tool/thinking output and add toggle
2026-01-20 11:57:21 +00:00
Peter Steinberger 5a42f7cabd fix: align bird skill metadata and flags (#1302) (thanks @odysseus0) 2026-01-20 11:55:14 +00:00
Peter Steinberger f2c25c5f40 Merge pull request #1302 from odysseus0/docs/bird-skill-update
docs(bird): update skill for v0.5-0.8 features
2026-01-20 11:53:56 +00:00
Doug von Kohorn 528524e4c7 fix(session): preserve command argument casing 2026-01-20 12:53:45 +01:00
Peter Steinberger 7e08de4a5f fix: add nextcloud talk manifest (#1297) (thanks @ysqander) 2026-01-20 11:38:11 +00:00
Peter Steinberger c9e3c14f9c fix: finalize exec fish fallback (#1297) (thanks @ysqander) 2026-01-20 11:25:49 +00:00
Peter Steinberger 636a8e3181 fix: merge plugin manifest types 2026-01-20 11:22:28 +00:00
Peter Steinberger 660f87278c refactor: plugin catalog + nextcloud policy 2026-01-20 11:22:27 +00:00
Peter Steinberger 9ec1fb4a80 Merge pull request #1297 from ysqander/fix/fish-shell-bash
exec: prefer bash when fish is default shell
2026-01-20 11:13:35 +00:00
Peter Steinberger 74757cd5af fix: stabilize gateway defaults 2026-01-20 11:11:26 +00:00
Peter Steinberger 9f75550702 Merge pull request #1294 from bradleypriest/pr/sessions-labels
ui(sessions): support editing session labels
2026-01-20 11:05:46 +00:00
Peter Steinberger 8214ab507c Merge pull request #1272 from clawdbot/shadow/config-plugin-validation
Config: validate plugin config
2026-01-20 11:03:38 +00:00
Shadowandthewilloftheshadow 2f6d5805de fix: enforce plugin config schemas (#1272) (thanks @thewilloftheshadow)
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-20 11:03:17 +00:00
Peter Steinberger 48f733e4b3 refactor: use command lane enum 2026-01-20 10:51:25 +00:00
Peter Steinberger e5f7435d9f fix: sync device scopes on token rotation 2026-01-20 10:46:33 +00:00
Peter Steinberger 322c8dc4fc Merge pull request #1208 from 24601/fix/slack-bolt-import
fix(slack): handle bolt import for CJS/ESM compatibility
2026-01-20 10:46:27 +00:00
Peter Steinberger cf04b0e3bf fix: align gateway presence + config defaults tests (#1208) (thanks @24601) 2026-01-20 10:45:59 +00:00
Peter Steinberger 0f9f510dd9 refactor: centralize concurrency resolution 2026-01-20 10:41:56 +00:00
Peter Steinberger e110cf4fb1 chore: keep vitest workers at 4 2026-01-20 10:41:09 +00:00
Peter Steinberger 6942ceb7a9 test: update gateway node/e2e tests 2026-01-20 10:41:09 +00:00
Peter Steinberger 47cf28f6b6 fix: prevent duplicate cron runs across hot reloads 2026-01-20 10:36:46 +00:00
Peter Steinberger 115b4379bf fix: handle Slack Bolt import + gateway node ids (#1208) (thanks @24601) 2026-01-20 10:33:40 +00:00
Basit Mustafa a6db1edee3 test(slack): mock HTTPReceiver 2026-01-20 10:33:01 +00:00
Basit Mustafa 4ed1b7c7ed fix(slack): resolve bolt constructors 2026-01-20 10:33:01 +00:00
Basit Mustafa 7ef7b94bc0 fix(slack): handle bolt import for CJS/ESM compatibility 2026-01-20 10:33:01 +00:00
Peter Steinberger da7da30b22 style: format agent defaults import 2026-01-20 10:31:16 +00:00
Peter Steinberger 213d9b47b0 refactor: centralize agent concurrency defaults 2026-01-20 10:31:16 +00:00
Peter Steinberger d88b239d3c feat: add device token auth and devices cli 2026-01-20 10:30:53 +00:00
Peter Steinberger 1c02de1309 chore: raise default agent concurrency 2026-01-20 10:08:26 +00:00
Peter Steinbergerandhumanwritten d3c2b83f88 fix: avoid context-window-too-small misclassification (#1266, thanks @humanwritten)
Co-authored-by: humanwritten <humanwritten@users.noreply.github.com>
2026-01-20 10:07:05 +00:00
humanwrittenandClaude 93f80894a3 fix: prevent context-window-too-small error from being misclassified
The regex `/context window/i` was matching "Model context window too small"
and rewriting it to generic "Context overflow" message, hiding the actual
problem from users.

Add exclusion for "too small|minimum is" patterns so the original
informative error message passes through.

🤖 AI-assisted (Claude) - tested on local instance

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 10:07:05 +00:00
Peter Steinberger c440cc2f84 fix: preserve gateway presence instanceId 2026-01-20 09:52:26 +00:00
ysqander 8ddedc3fc5 exec: prefer bash when fish is default shell 2026-01-20 17:42:02 +08:00
Peter Steinberger 2439c31844 chore: tune vitest parallelism 2026-01-20 09:40:10 +00:00
Peter Steinberger 292f21ae78 docs: add changelog entry for codex-cli dedupe 2026-01-20 09:38:56 +00:00
Peter SteinbergerandOliver Drobnik 9c2c4b1138 fix(auth): dedupe codex-cli profiles
Co-authored-by: Oliver Drobnik <oliver@cocoanetics.com>
2026-01-20 09:38:56 +00:00
George Pickett e0e33e12d1 Docs: frame skills eval gap in testing 2026-01-20 09:37:59 +00:00
Peter Steinberger eb5145c5d1 docs: mention tool_result_persist hook 2026-01-20 09:36:10 +00:00
Peter SteinbergerandZach Knickerbocker aedf4ce328 fix: treat OAuth refresh failures as auth errors (#1261) (thanks @zknicker)
Co-authored-by: Zach Knickerbocker <zknicker@users.noreply.github.com>
2026-01-20 09:31:41 +00:00
Peter Steinberger 94af5a72fc fix: prevent duplicate agent event emission 2026-01-20 09:25:20 +00:00
Peter Steinberger 9dbc1435a6 fix: enforce ws3 roles + node allowlist 2026-01-20 09:24:01 +00:00
Peter Steinberger 32a668e4d9 refactor: streamline TUI stream assembly updates 2026-01-20 08:57:42 +00:00
Peter Steinberger c17c7b4e24 Merge pull request #1235 from dougvk/feat/tool-dispatch-skill-commands
Plugin API: tool-dispatched skill commands + tool_result_persist hook
2026-01-20 08:52:05 +00:00
Peter Steinberger c5e732951b fix: prefer bundled plugin schema 2026-01-20 08:47:56 +00:00
Peter Steinberger 069b50635b fix: lazy-load pdf/image input deps 2026-01-20 08:47:56 +00:00
Peter SteinbergerandAaron 074db1905a fix: refactor TUI stream assembly (#1202, thanks @aaronveklabs)
Co-authored-by: Aaron <aaron@vektor-labs.com>
2026-01-20 08:36:54 +00:00
Aaron 9609a3af40 fix: check for error before early return in extractContentFromMessage
Addresses Codex review feedback - ensures error messages are surfaced
even when content is missing/undefined
2026-01-20 08:36:36 +00:00
Aaron 476087f879 fix(tui): buffer streaming messages by runId to prevent render ordering issues
Fixes #1172

- Add per-runId message buffering in ChatLog
- Separate thinking stream from content stream handling
- Ensure proper sequencing (thinking always before content)
- Model-agnostic: works with or without thinking tokens
2026-01-20 08:36:36 +00:00
Peter Steinberger 41b696fa83 chore: remove Peekaboo submodule 2026-01-20 08:31:06 +00:00
Peter Steinberger a5adedea91 refactor: add aws-sdk auth mode and tighten provider auth 2026-01-20 08:28:40 +00:00
Peter Steinberger 9266e542ab chore: remove peekaboo submodule references 2026-01-20 08:26:56 +00:00
Peter Steinberger f06ad4502b refactor: share responses input handling 2026-01-20 08:21:57 +00:00
Peter Steinberger e26c647828 fix: defer pdf deps and profile flag detection 2026-01-20 08:20:07 +00:00
Bradley Priest 51f1f23235 ui(sessions): support editing session labels
Expose session "label" as an editable field in the Sessions view and persist changes via sessions.patch.
2026-01-20 21:07:41 +13:00
Bradley Priest c9d02f0132 ui(chat): separate tool/thinking output and add toggle
- Render assistant reasoning as a distinct block (not merged into message text).\n- Detect tool-like messages reliably and style them separately.\n- Add a "🧠" toggle to hide/show tool + thinking output, persisted in UI settings.
2026-01-20 21:07:29 +13:00
Peter Steinberger bee72f1ae0 fix: guard systemd errors in doctor 2026-01-20 07:56:22 +00:00
Peter Steinberger 509bc81e28 Merge pull request #1288 from bradleypriest/pr/chat-session-url
ui(chat): persist session in URL and stabilize picker
2026-01-20 07:44:33 +00:00
Peter Steinberger 6d5195c890 refactor: normalize cli command hints 2026-01-20 07:43:00 +00:00
Peter Steinberger 11b9b6dba5 Merge pull request #1229 from RyanLisse/main
feat(gateway): add OpenResponses /v1/responses endpoint
2026-01-20 07:38:18 +00:00
Peter Steinberger bbc67f3754 fix: expand /v1/responses inputs (#1229) (thanks @RyanLisse) 2026-01-20 07:37:30 +00:00
Ryan Lisse 4f02c74dca test(gateway): add OpenResponses parity E2E tests
- Add schema validation tests for input_image, input_file, client tools
- Add buildAgentPrompt tests for turn-based tool flow
2026-01-20 07:37:01 +00:00
Ryan Lisse a5afe7bc2b feat(gateway): implement OpenResponses /v1/responses endpoint phase 2
- Add input_image and input_file support with SSRF protection
- Add client-side tools (Hosted Tools) support
- Add turn-based tool flow with function_call_output handling
- Export buildAgentPrompt for testing
2026-01-20 07:37:01 +00:00
Ryan LisseandClaude Opus 4.5 f4b03599f0 feat(gateway): add OpenResponses /v1/responses endpoint
Add a new `/v1/responses` endpoint implementing the OpenResponses API
standard for agentic workflows. This provides:

- Item-based input (messages, function_call_output, reasoning)
- Semantic streaming events (response.created, response.output_text.delta,
  response.completed, etc.)
- Full SSE event support with both event: and data: lines
- Configuration via gateway.http.endpoints.responses.enabled

The endpoint is disabled by default and can be enabled independently
from the existing Chat Completions endpoint.

Phase 1 implementation supports:
- String or ItemParam[] input
- system/developer/user/assistant message roles
- function_call_output items
- instructions parameter
- Agent routing via headers or model parameter
- Session key management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:37:01 +00:00
Ryan Lisse 7f6e87e918 build: update bundle hash and register Peekaboo submodule 2026-01-20 07:37:01 +00:00
Ryan Lisse e14ff8f407 fix(ios): replace FileManager.default with FileManager() for Swift 6.2 compatibility 2026-01-20 07:37:01 +00:00
Ryan Lisse 87d995bcde refactor(macos): replace FileManager.default for Swift 6 2026-01-20 07:37:01 +00:00
Peter Steinberger 44d55667de docs: optimize PR review calls 2026-01-20 07:34:15 +00:00
Peter Steinberger 3e546e691d fix: infer perplexity baseUrl from api key 2026-01-20 07:27:32 +00:00
Bradley Priest 5b0684ebcf ui(chat): persist session in URL and stabilize picker
- Keep the selected chat session in ?session=... for deep links and reloads.\n- Only apply the query param on the Chat tab (avoid leaking it across navigation).\n- Render session <option> entries with stable keys to prevent label glitches.
2026-01-20 19:17:03 +13:00
George Zhang 0bd99717be docs(bird): update skill for v0.5-0.8 features
- Add 18 missing commands (home, news, lists, engagement, etc.)
- Document pagination, media uploads, output options
- Add config file format and library usage
- Update posting advice (engagement actions now work)
- Add troubleshooting section
2026-01-20 03:49:40 +08:00
Sebastian 154c49511c Changelog: drop unrelated gateway fix 2026-01-19 13:19:09 -05:00
Sebastian 34462b3221 Config: allow Perplexity web_search provider 2026-01-19 13:03:59 -05:00
tsu 0372bdf6fe fix: add enabled property to groupConfigSchema for improved configuration 2026-01-19 20:25:17 +07:00
tsu cd8309cc31 chore: simplify user parsing logic in probeZalouser function 2026-01-19 19:18:04 +07:00
Doug von Kohorn c3a34408f3 feat: add tool_result_persist hook 2026-01-19 13:11:31 +01:00
Doug von Kohorn 9f280454ba feat: tool-dispatch skill commands 2026-01-19 13:11:25 +01:00
tsu 5d9a5b7958 feat: implement zalouser channel plugin with configuration and status monitoring 2026-01-19 14:26:16 +07:00
818 changed files with 51711 additions and 6125 deletions

No files matched your search

BIN
View File
Binary file not shown.
+366
View File
@@ -0,0 +1,366 @@
---
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
---
# Clawdbot Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
## Quick Reference
```bash
# Check divergence status
git fetch upstream && git rev-list --left-right --count main...upstream/main
# Full sync (rebase preferred)
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
# Check for Swift 6.2 issues after sync
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
```
---
## Step 1: Assess Divergence
```bash
git fetch upstream
git log --oneline --left-right main...upstream/main | head -20
```
This shows:
- `<` = your local commits (ahead)
- `>` = upstream commits you're missing (behind)
**Decision point:**
- Few local commits, many upstream → **Rebase** (cleaner history)
- Many local commits or shared branch → **Merge** (preserves history)
---
## Step 2A: Rebase Strategy (Preferred)
Replays your commits on top of upstream. Results in linear history.
```bash
# Ensure working tree is clean
git status
# Rebase onto upstream
git rebase upstream/main
```
### Handling Rebase Conflicts
```bash
# When conflicts occur:
# 1. Fix conflicts in the listed files
# 2. Stage resolved files
git add <resolved-files>
# 3. Continue rebase
git rebase --continue
# If a commit is no longer needed (already in upstream):
git rebase --skip
# To abort and return to original state:
git rebase --abort
```
### Common Conflict Patterns
| File | Resolution |
|------|------------|
| `package.json` | Take upstream deps, keep local scripts if needed |
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
| `*.patch` files | Usually take upstream version |
| Source files | Merge logic carefully, prefer upstream structure |
---
## Step 2B: Merge Strategy (Alternative)
Preserves all history with a merge commit.
```bash
git merge upstream/main --no-edit
```
Resolve conflicts same as rebase, then:
```bash
git add <resolved-files>
git commit
```
---
## Step 3: Rebuild Everything
After sync completes:
```bash
# Install dependencies (regenerates lock if needed)
pnpm install
# Build TypeScript
pnpm build
# Build UI assets
pnpm ui:build
# Run diagnostics
pnpm clawdbot doctor
```
---
## Step 4: Rebuild macOS App
```bash
# Full rebuild, sign, and launch
./scripts/restart-mac.sh
# Or just package without restart
pnpm mac:package
```
### Install to /Applications
```bash
# Kill running app
pkill -x "Clawdbot" || true
# Move old version
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
# Install new build
cp -R dist/Clawdbot.app /Applications/
# Launch
open /Applications/Clawdbot.app
```
---
## Step 4A: Verify macOS App & Agent
After rebuilding the macOS app, always verify it works correctly:
```bash
# Check gateway health
pnpm clawdbot health
# Verify no zombie processes
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
# Test agent functionality by sending a verification message
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
# Confirm the message was received on Telegram
# (Check your Telegram chat with the bot)
```
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
---
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
### Analyze-Mode Investigation
```bash
# Gather context with parallel agents
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
### Common Swift 6.2 Fixes
**FileManager.default Deprecation:**
```bash
# Search for deprecated usage
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
# Replace with proper initialization
# OLD: FileManager.default
# NEW: FileManager()
```
**Thread.isMainThread Deprecation:**
```bash
# Search for deprecated usage
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
# Replace with modern concurrency check
# OLD: Thread.isMainThread
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
```
### Peekaboo Submodule Fixes
```bash
# Check Peekaboo for concurrency issues
cd src/canvas-host/a2ui
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
# Fix and rebuild submodule
cd /Volumes/Main SSD/Developer/clawdis
pnpm canvas:a2ui:bundle
```
### macOS App Concurrency Fixes
```bash
# Check macOS app for issues
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
# Clean and rebuild after fixes
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Model Configuration Updates
If upstream introduced new model configurations:
```bash
# Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update clawdbot.json with fallback chains
# Add model fallback configurations as needed
```
---
## Step 6: Verify & Push
```bash
# Verify everything works
pnpm clawdbot health
pnpm test
# Push (force required after rebase)
git push origin main --force-with-lease
# Or regular push after merge
git push origin main
```
---
## Troubleshooting
### Build Fails After Sync
```bash
# Clean and rebuild
rm -rf node_modules dist
pnpm install
pnpm build
```
### Type Errors (Bun/Node Incompatibility)
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
### macOS App Crashes on Launch
Usually resource bundle mismatch. Full rebuild required:
```bash
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Patch Failures
```bash
# Check patch status
pnpm install 2>&1 | grep -i patch
# If patches fail, they may need updating for new dep versions
# Check patches/ directory against package.json patchedDependencies
```
### Swift 6.2 / macOS 26 SDK Build Failures
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
**Search-Mode Investigation:**
```bash
# Exhaustive search for deprecated APIs
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
**Quick Fix Commands:**
```bash
# Find all affected files
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
# Replace FileManager.default with FileManager()
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
# For Thread.isMainThread, need manual review of each usage
grep -rn "Thread\.isMainThread" --include="*.swift" .
```
**Rebuild After Fixes:**
```bash
# Clean all build artifacts
rm -rf apps/macos/.build apps/macos/.swiftpm
rm -rf src/canvas-host/a2ui/.build
# Rebuild Peekaboo bundle
pnpm canvas:a2ui:bundle
# Full macOS rebuild
./scripts/restart-mac.sh
```
---
## Automation Script
Save as `scripts/sync-upstream.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Fetching upstream..."
git fetch upstream
echo "==> Current divergence:"
git rev-list --left-right --count main...upstream/main
echo "==> Rebasing onto upstream/main..."
git rebase upstream/main
echo "==> Installing dependencies..."
pnpm install
echo "==> Building..."
pnpm build
pnpm ui:build
echo "==> Running doctor..."
pnpm clawdbot doctor
echo "==> Rebuilding macOS app..."
./scripts/restart-mac.sh
echo "==> Verifying gateway health..."
pnpm clawdbot health
echo "==> Checking for Swift 6.2 compatibility issues..."
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
echo " Run manual fixes or use analyze-mode investigation"
else
echo "✅ No obvious Swift deprecation issues found"
fi
echo "==> Testing agent functionality..."
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
```
+3
View File
@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
IDENTITY.md IDENTITY.md
USER.md USER.md
.tgz .tgz
# local tooling
.serena/
+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
+6
View File
@@ -41,6 +41,11 @@
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys. - Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines ## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
@@ -57,6 +62,7 @@
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section. - Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches. - PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing. - Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`. - PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor. - If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
+47 -3
View File
@@ -2,17 +2,58 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.20
### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3 ## 2026.1.19-3
### Changes ### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol. - Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete. - Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes ### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. - Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest. - UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- UI: centralize Control UI session routing + URL sync so chat deep links stay stable. (#1288) — thanks @bradleypriest. - Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2 ## 2026.1.19-2
@@ -31,9 +72,11 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair. - **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes ### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes ### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
@@ -46,6 +89,7 @@ Docs: https://docs.clawd.bot
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07. - TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169) - Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras. - CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5 ## 2026.1.18-5
@@ -71,8 +115,8 @@ Docs: https://docs.clawd.bot
## 2026.1.18-4 ## 2026.1.18-4
### Changes ### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule). - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo as a git submodule in postinstall. - macOS: stop syncing Peekaboo in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging. - CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. - Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
+32 -26
View File
@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`). Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development) ## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
@@ -258,12 +267,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
The Gateway alone delivers a great experience. All apps are optional and add extra features. The Gateway alone delivers a great experience. All apps are optional and add extra features.
If you plan to build/run companion apps, initialize submodules first: If you plan to build/run companion apps, follow the platform runbooks below.
```bash
git submodule update --init --recursive
./scripts/restart-mac.sh
```
### macOS (Clawdbot.app) (optional) ### macOS (Clawdbot.app) (optional)
@@ -476,24 +480,26 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+5 -5
View File
@@ -1,13 +1,13 @@
{ {
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748", "originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
"pins" : [ "pins" : [
{ {
"identity" : "elevenlabskit", "identity" : "commander",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/Commander.git",
"state" : { "state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.1.0" "version" : "0.2.1"
} }
}, },
{ {
+2 -2
View File
@@ -101,8 +101,8 @@ Environment variables:
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs. - Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development ## Development
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present) - Format: `./scripts/format.sh` (uses local `.swiftformat`)
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present) - Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
- Tests: `swift test` (uses swift-testing package) - Tests: `swift test` (uses swift-testing package)
## Roadmap ## Roadmap
+1 -6
View File
@@ -1,10 +1,5 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PEEKABOO_ROOT="${ROOT}/../peekaboo" CONFIG="${ROOT}/.swiftformat"
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
else
CONFIG="${ROOT}/.swiftformat"
fi
swiftformat --config "$CONFIG" "$ROOT/Sources" swiftformat --config "$CONFIG" "$ROOT/Sources"
+1 -6
View File
@@ -1,12 +1,7 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PEEKABOO_ROOT="${ROOT}/../peekaboo" CONFIG="${ROOT}/.swiftlint.yml"
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
else
CONFIG="$ROOT/.swiftlint.yml"
fi
if ! command -v swiftlint >/dev/null; then if ! command -v swiftlint >/dev/null; then
echo "swiftlint not installed" >&2 echo "swiftlint not installed" >&2
exit 1 exit 1
+1 -1
View File
@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal) ## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**. Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
Notes: Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). - The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601114 versionCode = 202601200
versionName = "2026.1.11-4" versionName = "2026.1.20"
} }
buildTypes { buildTypes {
@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext) val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController() val canvas = CanvasController()
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext) val location = LocationCaptureManager(appContext)
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey -> onConnected = { name, remote, mainSessionKey ->
operatorConnected = true operatorConnected = true
operatorStatusText = "Connected" operatorStatusText = "Connected"
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { _, _, _ ->
nodeConnected = true nodeConnected = true
nodeStatusText = "Connected" nodeStatusText = "Connected"
@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) } prefs.edit { putString(key, fingerprint.trim()) }
} }
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
private fun loadOrCreateInstanceId(): String { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
@@ -0,0 +1,26 @@
package com.clawdbot.android.gateway
import com.clawdbot.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}
fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = deviceId.trim().lowercase()
val normalizedRole = role.trim().lowercase()
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
}
@@ -51,7 +51,7 @@ class GatewayDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance() private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdbot-gateway._tcp." private val serviceType = "_clawdbot-gw._tcp."
private val wideAreaDomain = "clawdbot.internal." private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/GatewayDiscovery" private val logTag = "Clawdbot/GatewayDiscovery"
@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
class GatewaySession( class GatewaySession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore, private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
@@ -177,6 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>() private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>() private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient() private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway" private val loggerTag = "ClawdbotGateway"
@@ -253,7 +255,8 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch { scope.launch {
try { try {
sendConnect() val nonce = awaitConnectNonce()
sendConnect(nonce)
} catch (err: Throwable) { } catch (err: Throwable) {
connectDeferred.completeExceptionally(err) connectDeferred.completeExceptionally(err)
closeQuietly() closeQuietly()
@@ -288,16 +291,30 @@ class GatewaySession(
} }
} }
private suspend fun sendConnect() { private suspend fun sendConnect(connectNonce: String?) {
val payload = buildConnectParams() val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = 8_000) val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) { if (!res.ok) {
val msg = res.error?.message ?: "connect failed" val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg) throw IllegalStateException(msg)
} }
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull() val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults = val sessionDefaults =
@@ -308,7 +325,12 @@ class GatewaySession(
connectDeferred.complete(Unit) connectDeferred.complete(Unit)
} }
private fun buildConnectParams(): JsonObject { private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
val client = options.client val client = options.client
val locale = Locale.getDefault().toLanguageTag() val locale = Locale.getDefault().toLanguageTag()
val clientObj = val clientObj =
@@ -323,22 +345,20 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
} }
val authToken = token?.trim().orEmpty() val password = authPassword?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson = val authJson =
when { when {
authToken.isNotEmpty() -> authToken.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("token", JsonPrimitive(authToken)) put("token", JsonPrimitive(authToken))
} }
authPassword.isNotEmpty() -> password.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("password", JsonPrimitive(authPassword)) put("password", JsonPrimitive(password))
} }
else -> null else -> null
} }
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis() val signedAtMs = System.currentTimeMillis()
val payload = val payload =
buildDeviceAuthPayload( buildDeviceAuthPayload(
@@ -349,6 +369,7 @@ class GatewaySession(
scopes = options.scopes, scopes = options.scopes,
signedAtMs = signedAtMs, signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null, token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
) )
val signature = identityStore.signPayload(payload, identity) val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity) val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -359,6 +380,9 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey)) put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature)) put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs)) put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
} }
} else { } else {
null null
@@ -416,6 +440,13 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return val event = frame["event"].asStringOrNull() ?: return
val payloadJson = val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson) handleInvokeEvent(payloadJson)
return return
@@ -423,6 +454,21 @@ class GatewaySession(
onEvent(event, payloadJson) onEvent(event, payloadJson)
} }
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
}
private fun handleInvokeEvent(payloadJson: String) { private fun handleInvokeEvent(payloadJson: String) {
val payload = val payload =
try { try {
@@ -544,19 +590,26 @@ class GatewaySession(
scopes: List<String>, scopes: List<String>,
signedAtMs: Long, signedAtMs: Long,
token: String?, token: String?,
nonce: String?,
): String { ): String {
val scopeString = scopes.joinToString(",") val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty() val authToken = token.orEmpty()
return listOf( val version = if (nonce.isNullOrBlank()) "v1" else "v2"
"v1", val parts =
deviceId, mutableListOf(
clientId, version,
clientMode, deviceId,
role, clientId,
scopeString, clientMode,
signedAtMs.toString(), role,
authToken, scopeString,
).joinToString("|") signedAtMs.toString(),
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
} }
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
} }
private fun normalizeFingerprint(raw: String): String { private fun normalizeFingerprint(raw: String): String {
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
} }
@@ -160,14 +160,14 @@ actor CameraController {
defer { session.stopRunning() } defer { session.stopRunning() }
await Self.warmUpCaptureSession() await Self.warmUpCaptureSession()
let movURL = FileManager.default.temporaryDirectory let movURL = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager.default.temporaryDirectory let mp4URL = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
defer { defer {
try? FileManager.default.removeItem(at: movURL) try? FileManager().removeItem(at: movURL)
try? FileManager.default.removeItem(at: mp4URL) try? FileManager().removeItem(at: mp4URL)
} }
var delegate: MovieFileDelegate? var delegate: MovieFileDelegate?
+3 -3
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260120</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -29,7 +29,7 @@
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_clawdbot-gateway._tcp</string> <string>_clawdbot-gw._tcp</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string> <string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
+1 -1
View File
@@ -837,7 +837,7 @@ final class NodeAppModel {
fps: params.fps, fps: params.fps,
includeAudio: params.includeAudio, includeAudio: params.includeAudio,
outPath: nil) outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) } defer { try? FileManager().removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path)) let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable { struct Payload: Codable {
var format: String var format: String
@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
let includeAudio = includeAudio ?? true let includeAudio = includeAudio ?? true
let outURL = self.makeOutputURL(outPath: outPath) let outURL = self.makeOutputURL(outPath: outPath)
try? FileManager.default.removeItem(at: outURL) try? FileManager().removeItem(at: outURL)
return RecordConfig( return RecordConfig(
durationMs: durationMs, durationMs: durationMs,
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath) return URL(fileURLWithPath: outPath)
} }
return FileManager.default.temporaryDirectory return FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
} }
+8 -9
View File
@@ -1,15 +1,13 @@
Sources/Bridge/BridgeClient.swift Sources/Gateway/GatewayConnectionController.swift
Sources/Bridge/BridgeConnectionController.swift Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Bridge/BridgeDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift
Sources/Bridge/BridgeEndpointID.swift Sources/Gateway/KeychainStore.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift Sources/Chat/ChatSheet.swift
Sources/Chat/IOSBridgeChatTransport.swift Sources/Chat/IOSGatewayChatTransport.swift
Sources/ClawdbotApp.swift Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift Sources/RootCanvas.swift
Sources/RootTabs.swift Sources/RootTabs.swift
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift Sources/Settings/VoiceWakeWordsSettingsView.swift
+3 -3
View File
@@ -7,11 +7,11 @@ import Testing
@Test func stableIDForServiceDecodesAndNormalizesName() { @Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway \\032 Node\n", name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-gateway._tcp", type: "_clawdbot-gw._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node") #expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
} }
@Test func stableIDForNonServiceUsesEndpointDescription() { @Test func stableIDForNonServiceUsesEndpointDescription() {
@@ -22,7 +22,7 @@ import Testing
@Test func prettyDescriptionDecodesBonjourEscapes() { @Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway", name: "Clawdbot\\032Gateway",
type: "_clawdbot-gateway._tcp", type: "_clawdbot-gw._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>20260120</string>
</dict> </dict>
</plist> </plist>
+5 -5
View File
@@ -81,8 +81,8 @@ targets:
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.9" CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260109" CFBundleVersion: "20260120"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -92,7 +92,7 @@ targets:
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices: NSBonjourServices:
- _clawdbot-gateway._tcp - _clawdbot-gw._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway. NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing. NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always. NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.9" CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260109" CFBundleVersion: "20260120"
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438", "originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
"pins" : [ "pins" : [
{ {
"identity" : "axorcist", "identity" : "axorcist",
+4 -12
View File
@@ -12,8 +12,7 @@ let package = Package(
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]), .library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]), .library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]), .executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -67,20 +66,13 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
.executableTarget( .executableTarget(
name: "ClawdbotDiscoveryCLI", name: "ClawdbotMacCLI",
dependencies: [ dependencies: [
"ClawdbotDiscovery", "ClawdbotDiscovery",
], .product(name: "ClawdbotKit", package: "ClawdbotKit"),
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"), .product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
], ],
path: "Sources/ClawdbotWizardCLI", path: "Sources/ClawdbotMacCLI",
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
@@ -81,7 +81,7 @@ private struct EventRow: View {
return f.string(from: date) return f.string(from: date)
} }
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? { private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value } let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized), guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]), let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
@@ -98,7 +98,10 @@ struct AgentEventsWindow_Previews: PreviewProvider {
seq: 1, seq: 1,
stream: "tool", stream: "tool",
ts: Date().timeIntervalSince1970 * 1000, ts: Date().timeIntervalSince1970 * 1000,
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")], data: [
"phase": ClawdbotProtocol.AnyCodable("start"),
"name": ClawdbotProtocol.AnyCodable("bash"),
],
summary: nil) summary: nil)
AgentEventStore.shared.append(sample) AgentEventStore.shared.append(sample)
return AgentEventsWindow() return AgentEventsWindow()
@@ -23,7 +23,7 @@ enum AgentWorkspace {
} }
static func displayPath(for url: URL) -> String { static func displayPath(for url: URL) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path let home = FileManager().homeDirectoryForCurrentUser.path
let path = url.path let path = url.path
if path == home { return "~" } if path == home { return "~" }
if path.hasPrefix(home + "/") { if path.hasPrefix(home + "/") {
@@ -44,12 +44,12 @@ enum AgentWorkspace {
} }
static func workspaceEntries(workspaceURL: URL) throws -> [String] { static func workspaceEntries(workspaceURL: URL) throws -> [String] {
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path) let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
return contents.filter { !self.ignoredEntries.contains($0) } return contents.filter { !self.ignoredEntries.contains($0) }
} }
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager.default let fm = FileManager()
var isDir: ObjCBool = false var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true return true
@@ -66,7 +66,7 @@ enum AgentWorkspace {
} }
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager.default let fm = FileManager()
var isDir: ObjCBool = false var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe return .safe
@@ -90,29 +90,29 @@ enum AgentWorkspace {
static func bootstrap(workspaceURL: URL) throws -> URL { static func bootstrap(workspaceURL: URL) throws -> URL {
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL) let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager.default.fileExists(atPath: agentsURL.path) { if !FileManager().fileExists(atPath: agentsURL.path) {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
} }
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
if !FileManager.default.fileExists(atPath: soulURL.path) { if !FileManager().fileExists(atPath: soulURL.path) {
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
} }
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
if !FileManager.default.fileExists(atPath: identityURL.path) { if !FileManager().fileExists(atPath: identityURL.path) {
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
} }
let userURL = workspaceURL.appendingPathComponent(self.userFilename) let userURL = workspaceURL.appendingPathComponent(self.userFilename)
if !FileManager.default.fileExists(atPath: userURL.path) { if !FileManager().fileExists(atPath: userURL.path) {
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
} }
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) { if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) {
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
} }
@@ -120,7 +120,7 @@ enum AgentWorkspace {
} }
static func needsBootstrap(workspaceURL: URL) -> Bool { static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager.default let fm = FileManager()
var isDir: ObjCBool = false var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true return true
@@ -305,7 +305,7 @@ enum AgentWorkspace {
if let dev = self.devTemplateURL(named: named) { if let dev = self.devTemplateURL(named: named) {
urls.append(dev) urls.append(dev)
} }
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs") urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname) .appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)) .appendingPathComponent(named))
@@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()]) NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path)) .disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
Button("Refresh") { Button("Refresh") {
self.refresh() self.refresh()
@@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
return URL(fileURLWithPath: expanded, isDirectory: true) return URL(fileURLWithPath: expanded, isDirectory: true)
} }
return FileManager.default.homeDirectoryForCurrentUser return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent(".clawdbot", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true) .appendingPathComponent("credentials", isDirectory: true)
} }
@@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
} }
let home = FileManager.default.homeDirectoryForCurrentUser let home = FileManager().homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
@@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
static func importLegacyAnthropicOAuthIfNeeded() -> URL? { static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL() let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil } guard !FileManager().fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() { for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue } guard FileManager().fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue } guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue } guard let storage = self.loadStorage(at: url) else { continue }
do { do {
@@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
} }
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile } guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
guard let data = try? Data(contentsOf: url) else { return .unreadableFile } guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
@@ -360,7 +360,7 @@ enum ClawdbotOAuthStore {
private static func saveStorage(_ storage: [String: Any]) throws { private static func saveStorage(_ storage: [String: Any]) throws {
let dir = self.oauthDir() let dir = self.oauthDir()
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: dir, at: dir,
withIntermediateDirectories: true, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]) attributes: [.posixPermissions: 0o700])
@@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
withJSONObject: storage, withJSONObject: storage,
options: [.prettyPrinted, .sortedKeys]) options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} }
} }
@@ -1,6 +1,11 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = ClawdbotKit.AnyCodable
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
extension AnyCodable { extension AnyCodable {
var stringValue: String? { self.value as? String } var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool } var boolValue: Bool? { self.value as? Bool }
@@ -20,3 +25,23 @@ extension AnyCodable {
} }
} }
} }
extension ClawdbotProtocol.AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: ClawdbotProtocol.AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [ClawdbotProtocol.AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}
@@ -61,7 +61,7 @@ enum CLIInstaller {
} }
private static func installPrefix() -> String { private static func installPrefix() -> String {
FileManager.default.homeDirectoryForCurrentUser FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot") .appendingPathComponent(".clawdbot")
.path .path
} }
@@ -167,20 +167,20 @@ actor CameraCaptureService {
defer { session.stopRunning() } defer { session.stopRunning() }
await Self.warmUpCaptureSession() await Self.warmUpCaptureSession()
let tmpMovURL = FileManager.default.temporaryDirectory let tmpMovURL = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
defer { try? FileManager.default.removeItem(at: tmpMovURL) } defer { try? FileManager().removeItem(at: tmpMovURL) }
let outputURL: URL = { let outputURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath) return URL(fileURLWithPath: outPath)
} }
return FileManager.default.temporaryDirectory return FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
}() }()
// Ensure we don't fail exporting due to an existing file. // Ensure we don't fail exporting due to an existing file.
try? FileManager.default.removeItem(at: outputURL) try? FileManager().removeItem(at: outputURL)
let logger = self.logger let logger = self.logger
var delegate: MovieFileDelegate? var delegate: MovieFileDelegate?
@@ -25,7 +25,7 @@ final class CanvasManager {
var defaultAnchorProvider: (() -> NSRect?)? var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = { private nonisolated static let canvasRoot: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true) return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
}() }()
@@ -83,7 +83,7 @@ final class CanvasManager {
self.panelSessionKey = nil self.panelSessionKey = nil
Self.logger.debug("showDetailed ensure canvas root dir") Self.logger.debug("showDetailed ensure canvas root dir")
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
Self.logger.debug("showDetailed init CanvasWindowController") Self.logger.debug("showDetailed init CanvasWindowController")
let controller = try CanvasWindowController( let controller = try CanvasWindowController(
sessionKey: session, sessionKey: session,
@@ -258,7 +258,7 @@ final class CanvasManager {
// (Avoid treating Canvas routes like "/" as filesystem paths.) // (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") { if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
return URL(fileURLWithPath: trimmed) return URL(fileURLWithPath: trimmed)
} }
} }
@@ -293,7 +293,7 @@ final class CanvasManager {
} }
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager.default let fm = FileManager()
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed .map(String.init) ?? trimmed
@@ -331,7 +331,7 @@ final class CanvasManager {
} }
private static func indexExists(in dir: URL) -> Bool { private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager.default let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false) let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return true } if fm.fileExists(atPath: a.path) { return true }
let b = dir.appendingPathComponent("index.htm", isDirectory: false) let b = dir.appendingPathComponent("index.htm", isDirectory: false)
@@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.isEmpty { if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false) let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false) let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path), if !FileManager().fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path) !FileManager().fileExists(atPath: indexB.path)
{ {
return self.scaffoldPage(sessionRoot: sessionRoot) return self.scaffoldPage(sessionRoot: sessionRoot)
} }
@@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
} }
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager.default let fm = FileManager()
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false var isDir: ObjCBool = false
@@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
} }
private func resolveIndex(in dir: URL) -> URL? { private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager.default let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false) let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a } if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false) let b = dir.appendingPathComponent("index.htm", isDirectory: false)
@@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true) try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready") canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root) self.schemeHandler = CanvasSchemeHandler(root: root)
@@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if path == "/" || path.isEmpty { if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path), if !FileManager().fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path) !FileManager().fileExists(atPath: indexB.path)
{ {
return return
} }
@@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// (Avoid treating Canvas routes like "/" as filesystem paths.) // (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") { if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed) let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url) self.loadFile(url)
@@ -426,34 +426,17 @@ extension ChannelsSettings {
} }
private func resolveChannelTitle(_ id: String) -> String { private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty { let label = self.store.resolveChannelLabel(id)
return label if label != id { return label }
}
return id.prefix(1).uppercased() + id.dropFirst() return id.prefix(1).uppercased() + id.dropFirst()
} }
private func resolveChannelDetailTitle(_ id: String) -> String { private func resolveChannelDetailTitle(_ id: String) -> String {
switch id { self.store.resolveChannelDetailLabel(id)
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
switch id { self.store.resolveChannelSystemImage(id)
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
} }
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable? let application: AnyCodable?
} }
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double let ts: Double
let channelOrder: [String] let channelOrder: [String]
let channelLabels: [String: String] let channelLabels: [String: String]
let channelDetailLabels: [String: String]?
let channelSystemImages: [String: String]?
let channelMeta: [ChannelUiMetaEntry]?
let channels: [String: AnyCodable] let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]] let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String] let channelDefaultAccountId: [String: String]
@@ -217,6 +227,47 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map(\.id)
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
} }
@@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
static func loadDict() -> [String: Any] { static func loadDict() -> [String: Any] {
let url = self.url() let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] } guard FileManager().fileExists(atPath: url.path) else { return [:] }
do { do {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
guard let root = self.parseConfigData(data) else { guard let root = self.parseConfigData(data) else {
@@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
do { do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url() let url = self.url()
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
@@ -21,7 +21,7 @@ enum ClawdbotPaths {
if let override = ClawdbotEnv.path(self.stateDirEnv) { if let override = ClawdbotEnv.path(self.stateDirEnv) {
return URL(fileURLWithPath: override, isDirectory: true) return URL(fileURLWithPath: override, isDirectory: true)
} }
return FileManager.default.homeDirectoryForCurrentUser return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent(".clawdbot", isDirectory: true)
} }
@@ -6,9 +6,9 @@ enum CommandResolver {
static func gatewayEntrypoint(in root: URL) -> String? { static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry } if FileManager().isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry } if FileManager().isReadableFile(atPath: binEntry) { return binEntry }
return nil return nil
} }
@@ -47,16 +47,16 @@ enum CommandResolver {
static func projectRoot() -> URL { static func projectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored), let url = self.expandPath(stored),
FileManager.default.fileExists(atPath: url.path) FileManager().fileExists(atPath: url.path)
{ {
return url return url
} }
let fallback = FileManager.default.homeDirectoryForCurrentUser let fallback = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot") .appendingPathComponent("Projects/clawdbot")
if FileManager.default.fileExists(atPath: fallback.path) { if FileManager().fileExists(atPath: fallback.path) {
return fallback return fallback
} }
return FileManager.default.homeDirectoryForCurrentUser return FileManager().homeDirectoryForCurrentUser
} }
static func setProjectRoot(_ path: String) { static func setProjectRoot(_ path: String) {
@@ -70,7 +70,7 @@ enum CommandResolver {
static func preferredPaths() -> [String] { static func preferredPaths() -> [String] {
let current = ProcessInfo.processInfo.environment["PATH"]? let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? [] .split(separator: ":").map(String.init) ?? []
let home = FileManager.default.homeDirectoryForCurrentUser let home = FileManager().homeDirectoryForCurrentUser
let projectRoot = self.projectRoot() let projectRoot = self.projectRoot()
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
} }
@@ -99,10 +99,10 @@ enum CommandResolver {
let bin = base.appendingPathComponent("bin") let bin = base.appendingPathComponent("bin")
let nodeBin = base.appendingPathComponent("tools/node/bin") let nodeBin = base.appendingPathComponent("tools/node/bin")
var paths: [String] = [] var paths: [String] = []
if FileManager.default.fileExists(atPath: bin.path) { if FileManager().fileExists(atPath: bin.path) {
paths.append(bin.path) paths.append(bin.path)
} }
if FileManager.default.fileExists(atPath: nodeBin.path) { if FileManager().fileExists(atPath: nodeBin.path) {
paths.append(nodeBin.path) paths.append(nodeBin.path)
} }
return paths return paths
@@ -113,13 +113,13 @@ enum CommandResolver {
// Volta // Volta
let volta = home.appendingPathComponent(".volta/bin") let volta = home.appendingPathComponent(".volta/bin")
if FileManager.default.fileExists(atPath: volta.path) { if FileManager().fileExists(atPath: volta.path) {
bins.append(volta.path) bins.append(volta.path)
} }
// asdf // asdf
let asdf = home.appendingPathComponent(".asdf/shims") let asdf = home.appendingPathComponent(".asdf/shims")
if FileManager.default.fileExists(atPath: asdf.path) { if FileManager().fileExists(atPath: asdf.path) {
bins.append(asdf.path) bins.append(asdf.path)
} }
@@ -137,10 +137,10 @@ enum CommandResolver {
} }
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
guard FileManager.default.fileExists(atPath: base.path) else { return [] } guard FileManager().fileExists(atPath: base.path) else { return [] }
let entries: [String] let entries: [String]
do { do {
entries = try FileManager.default.contentsOfDirectory(atPath: base.path) entries = try FileManager().contentsOfDirectory(atPath: base.path)
} catch { } catch {
return [] return []
} }
@@ -167,7 +167,7 @@ enum CommandResolver {
for entry in sorted { for entry in sorted {
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
let node = binDir.appendingPathComponent("node") let node = binDir.appendingPathComponent("node")
if FileManager.default.isExecutableFile(atPath: node.path) { if FileManager().isExecutableFile(atPath: node.path) {
paths.append(binDir.path) paths.append(binDir.path)
} }
} }
@@ -177,7 +177,7 @@ enum CommandResolver {
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in searchPaths ?? self.preferredPaths() { for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager.default.isExecutableFile(atPath: candidate) { if FileManager().isExecutableFile(atPath: candidate) {
return candidate return candidate
} }
} }
@@ -191,12 +191,12 @@ enum CommandResolver {
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? { static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
let root = projectRoot ?? self.projectRoot() let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
} }
static func nodeCliPath() -> String? { static func nodeCliPath() -> String? {
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil return FileManager().isReadableFile(atPath: candidate) ? candidate : nil
} }
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool { static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
@@ -459,7 +459,7 @@ enum CommandResolver {
private static func expandPath(_ path: String) -> URL? { private static func expandPath(_ path: String) -> URL? {
var expanded = path var expanded = path
if expanded.hasPrefix("~") { if expanded.hasPrefix("~") {
let home = FileManager.default.homeDirectoryForCurrentUser.path let home = FileManager().homeDirectoryForCurrentUser.path
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
} }
return URL(fileURLWithPath: expanded) return URL(fileURLWithPath: expanded)
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
let seq: Int let seq: Int
let stream: String let stream: String
let ts: Double let ts: Double
let data: [String: AnyCodable] let data: [String: ClawdbotProtocol.AnyCodable]
let summary: String? let summary: String?
} }
@@ -156,8 +156,8 @@ final class ControlChannel {
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
do { do {
let rawParams = params?.reduce(into: [String: AnyCodable]()) { let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
$0[$1.key] = AnyCodable($1.value.base) $0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
} }
let data = try await GatewayConnection.shared.request( let data = try await GatewayConnection.shared.request(
method: method, method: method,
@@ -346,7 +346,7 @@ final class ControlChannel {
let phase = event.data["phase"]?.value as? String ?? "" let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String let meta = event.data["meta"]?.value as? String
let args = event.data["args"]?.value as? [String: AnyCodable] let args = Self.bridgeToProtocolArgs(event.data["args"])
WorkActivityStore.shared.handleTool( WorkActivityStore.shared.handleTool(
sessionKey: sessionKey, sessionKey: sessionKey,
phase: phase, phase: phase,
@@ -357,6 +357,27 @@ final class ControlChannel {
break break
} }
} }
private static func bridgeToProtocolArgs(
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
{
guard let value else { return nil }
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
return dict
}
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
let data = try? JSONEncoder().encode(dict),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
return nil
}
} }
extension Notification.Name { extension Notification.Name {
@@ -42,7 +42,8 @@ extension CronJobEditor {
self.thinking = thinking ?? "" self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel) let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = to ?? "" self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false self.bestEffortDeliver = bestEffortDeliver ?? false
} }
@@ -210,7 +211,8 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver payload["deliver"] = self.deliver
if self.deliver { if self.deliver {
payload["channel"] = self.channel.rawValue let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to } if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver payload["bestEffortDeliver"] = self.bestEffortDeliver
@@ -14,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic" self.agentMessage = "Run diagnostic"
self.deliver = true self.deliver = true
self.channel = .last self.channel = "last"
self.to = "+15551230000" self.to = "+15551230000"
self.thinking = "low" self.thinking = "low"
self.timeoutSeconds = "90" self.timeoutSeconds = "90"
@@ -1,10 +1,12 @@
import ClawdbotProtocol import ClawdbotProtocol
import Observation
import SwiftUI import SwiftUI
struct CronJobEditor: View { struct CronJobEditor: View {
let job: CronJob? let job: CronJob?
@Binding var isSaving: Bool @Binding var isSaving: Bool
@Binding var error: String? @Binding var error: String?
@Bindable var channelsStore: ChannelsStore
let onCancel: () -> Void let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void let onSave: ([String: AnyCodable]) -> Void
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
@State var systemEventText: String = "" @State var systemEventText: String = ""
@State var agentMessage: String = "" @State var agentMessage: String = ""
@State var deliver: Bool = false @State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last @State var channel: String = "last"
@State var to: String = "" @State var to: String = ""
@State var thinking: String = "" @State var thinking: String = ""
@State var timeoutSeconds: String = "" @State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false @State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron" @State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) {
options.append(trimmed)
}
var seen = Set<String>()
return options.filter { seen.insert($0).inserted }
}
func channelLabel(for id: String) -> String {
if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
GridRow { GridRow {
self.gridLabel("Channel") self.gridLabel("Channel")
Picker("", selection: self.$channel) { Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last) ForEach(self.channelOptions, id: \.self) { channel in
Text("whatsapp").tag(GatewayAgentChannel.whatsapp) Text(self.channelLabel(for: channel)).tag(channel)
Text("telegram").tag(GatewayAgentChannel.telegram) }
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -8,13 +8,20 @@ extension CronSettings {
self.content self.content
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.onAppear { self.store.start() } .onAppear {
.onDisappear { self.store.stop() } self.store.start()
self.channelsStore.start()
}
.onDisappear {
self.store.stop()
self.channelsStore.stop()
}
.sheet(isPresented: self.$showEditor) { .sheet(isPresented: self.$showEditor) {
CronJobEditor( CronJobEditor(
job: self.editingJob, job: self.editingJob,
isSaving: self.$isSaving, isSaving: self.$isSaving,
error: self.$editorError, error: self.$editorError,
channelsStore: self.channelsStore,
onCancel: { onCancel: {
self.showEditor = false self.showEditor = false
self.editingJob = nil self.editingJob = nil
@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
durationMs: 1234, durationMs: 1234,
nextRunAtMs: nil), nextRunAtMs: nil),
] ]
return CronSettings(store: store) return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }
@@ -103,7 +103,7 @@ extension CronSettings {
store.selectedJobId = job.id store.selectedJobId = job.id
store.runEntries = [run] store.runEntries = [run]
let view = CronSettings(store: store) let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
_ = view.body _ = view.body
_ = view.jobRow(job) _ = view.jobRow(job)
_ = view.jobContextMenu(job) _ = view.jobContextMenu(job)
@@ -3,13 +3,15 @@ import SwiftUI
struct CronSettings: View { struct CronSettings: View {
@Bindable var store: CronJobsStore @Bindable var store: CronJobsStore
@Bindable var channelsStore: ChannelsStore
@State var showEditor = false @State var showEditor = false
@State var editingJob: CronJob? @State var editingJob: CronJob?
@State var editorError: String? @State var editorError: String?
@State var isSaving = false @State var isSaving = false
@State var confirmDelete: CronJob? @State var confirmDelete: CronJob?
init(store: CronJobsStore = .shared) { init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
self.store = store self.store = store
self.channelsStore = channelsStore
} }
} }
@@ -26,7 +26,7 @@ enum DebugActions {
static func openLog() { static func openLog() {
let path = self.pinoLogPath() let path = self.pinoLogPath()
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else { guard FileManager().fileExists(atPath: path) else {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Log file not found" alert.messageText = "Log file not found"
alert.informativeText = path alert.informativeText = path
@@ -38,7 +38,7 @@ enum DebugActions {
@MainActor @MainActor
static func openConfigFolder() { static func openConfigFolder() {
let url = FileManager.default let url = FileManager()
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent(".clawdbot", isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url]) NSWorkspace.shared.activateFileViewerSelecting([url])
@@ -55,7 +55,7 @@ enum DebugActions {
} }
let path = self.resolveSessionStorePath() let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)
if FileManager.default.fileExists(atPath: path) { if FileManager().fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url]) NSWorkspace.shared.activateFileViewerSelecting([url])
} else { } else {
NSWorkspace.shared.open(url.deletingLastPathComponent()) NSWorkspace.shared.open(url.deletingLastPathComponent())
@@ -195,7 +195,7 @@ enum DebugActions {
@MainActor @MainActor
private static func resolveSessionStorePath() -> String { private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath let defaultPath = SessionLoader.defaultStorePath
let configURL = FileManager.default.homeDirectoryForCurrentUser let configURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/clawdbot.json") .appendingPathComponent(".clawdbot/clawdbot.json")
guard guard
let data = try? Data(contentsOf: configURL), let data = try? Data(contentsOf: configURL),
@@ -354,7 +354,7 @@ struct DebugSettings: View {
Button("Save") { self.saveRelayRoot() } Button("Save") { self.saveRelayRoot() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
Button("Reset") { Button("Reset") {
let def = FileManager.default.homeDirectoryForCurrentUser let def = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot").path .appendingPathComponent("Projects/clawdbot").path
self.gatewayRootInput = def self.gatewayRootInput = def
self.saveRelayRoot() self.saveRelayRoot()
@@ -743,7 +743,7 @@ struct DebugSettings: View {
do { do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
@@ -776,7 +776,7 @@ struct DebugSettings: View {
} }
private func configURL() -> URL { private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot") .appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
} }
@@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
} }
nonisolated static func logDirectoryURL() -> URL { nonisolated static func logDirectoryURL() -> URL {
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) ?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library return library
.appendingPathComponent("Logs", isDirectory: true) .appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("Clawdbot", isDirectory: true) .appendingPathComponent("Clawdbot", isDirectory: true)
@@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
} }
func clear() throws { func clear() throws {
let fm = FileManager.default let fm = FileManager()
let base = Self.logFileURL() let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) { if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base) try fm.removeItem(at: base)
@@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
} }
private func ensureDirectory() throws { private func ensureDirectory() throws {
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: Self.logDirectoryURL(), at: Self.logDirectoryURL(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
} }
@@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
line.append(data) line.append(data)
line.append(0x0A) // newline line.append(0x0A) // newline
let fm = FileManager.default let fm = FileManager()
if !fm.fileExists(atPath: url.path) { if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil) fm.createFile(atPath: url.path, contents: nil)
} }
@@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
private func rotateIfNeeded() throws { private func rotateIfNeeded() throws {
let url = Self.logFileURL() let url = Self.logFileURL()
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber let size = attrs[.size] as? NSNumber
else { return } else { return }
if size.int64Value < self.maxBytes { return } if size.int64Value < self.maxBytes { return }
let fm = FileManager.default let fm = FileManager()
let oldest = self.rotatedURL(index: self.maxBackups) let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) { if fm.fileExists(atPath: oldest.path) {
@@ -176,7 +176,7 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot { static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL() let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot( return ExecApprovalsSnapshot(
path: url.path, path: url.path,
exists: false, exists: false,
@@ -216,7 +216,7 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile { static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL() let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
} }
do { do {
@@ -238,11 +238,11 @@ enum ExecApprovalsStore {
encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file) let data = try encoder.encode(file)
let url = self.fileURL() let url = self.fileURL()
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch { } catch {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -442,11 +442,11 @@ enum ExecApprovalsStore {
private static func expandPath(_ raw: String) -> String { private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" { if trimmed == "~" {
return FileManager.default.homeDirectoryForCurrentUser.path return FileManager().homeDirectoryForCurrentUser.path
} }
if trimmed.hasPrefix("~/") { if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2) let suffix = trimmed.dropFirst(2)
return FileManager.default.homeDirectoryForCurrentUser return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(String(suffix)).path .appendingPathComponent(String(suffix)).path
} }
return trimmed return trimmed
@@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable {
return expanded return expanded
} }
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
} }
let searchPaths = self.searchPaths(from: env) let searchPaths = self.searchPaths(from: env)
@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
var needsScreenRecording: Bool? var needsScreenRecording: Bool?
var agentId: String? var agentId: String?
var sessionKey: String? var sessionKey: String?
var approvalDecision: ExecApprovalDecision?
} }
private struct ExecHostRunResult: Codable { private struct ExecHostRunResult: Codable {
@@ -258,6 +259,20 @@ enum ExecApprovalsPromptPresenter {
@MainActor @MainActor
private enum ExecHostExecutor { private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
private static let blockedEnvKeys: Set<String> = [ private static let blockedEnvKeys: Set<String> = [
"PATH", "PATH",
"NODE_OPTIONS", "NODE_OPTIONS",
@@ -276,14 +291,93 @@ private enum ExecHostExecutor {
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else { guard !command.isEmpty else {
return ExecHostResponse( return self.errorResponse(
type: "exec-res", code: "INVALID_REQUEST",
id: UUID().uuidString, message: "command required",
ok: false, reason: "invalid")
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
} }
let context = await self.buildContext(request: request, command: command)
if context.security == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny")
}
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
}
var approvedByAsk = approvalDecision != nil
if self.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: request.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath))
switch decision {
case .deny:
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
case .allowAlways:
approvedByAsk = true
self.persistAllowlistEntry(decision: decision, context: context)
case .allowOnce:
approvedByAsk = true
}
}
self.persistAllowlistEntry(decision: approvalDecision, context: context)
if context.security == .allowlist,
context.allowlistMatch == nil,
!context.skillAllow,
!approvedByAsk
{
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss")
}
if let match = context.allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: context.resolution?.resolvedPath)
}
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
return errorResponse
}
return await self.runCommand(
command: command,
cwd: request.cwd,
env: context.env,
timeoutMs: request.timeoutMs)
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString( let displayCommand = ExecCommandFormatter.displayString(
for: command, for: command,
rawCommand: request.rawCommand) rawCommand: request.rawCommand)
@@ -309,102 +403,72 @@ private enum ExecHostExecutor {
} else { } else {
skillAllow = false skillAllow = false
} }
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
}
if security == .deny { private static func requiresAsk(
return ExecHostResponse( ask: ExecAsk,
type: "exec-res", security: ExecSecurity,
id: UUID().uuidString, allowlistMatch: ExecAllowlistEntry?,
ok: false, skillAllow: Bool) -> Bool
payload: nil, {
error: ExecHostError( if ask == .always { return true }
code: "UNAVAILABLE", if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
message: "SYSTEM_RUN_DISABLED: security=deny", return false
reason: "security=deny")) }
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
return
} }
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
let requiresAsk: Bool = { private static func allowlistPattern(
if ask == .always { return true } command: [String],
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } resolution: ExecCommandResolution?) -> String?
return false {
}() let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
var approvedByAsk = false private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
if requiresAsk { guard needsScreenRecording == true else { return nil }
let decision = ExecApprovalsPromptPresenter.prompt( let authorized = await PermissionManager
ExecApprovalPromptRequest( .status([.screenRecording])[.screenRecording] ?? false
command: displayCommand, if authorized { return nil }
cwd: request.cwd, return self.errorResponse(
host: "node", code: "UNAVAILABLE",
security: security.rawValue, message: "PERMISSION_MISSING: screenRecording",
ask: ask.rawValue, reason: "permission:screenRecording")
agentId: trimmedAgent, }
resolvedPath: resolution?.resolvedPath))
switch decision { private static func runCommand(
case .deny: command: [String],
return ExecHostResponse( cwd: String?,
type: "exec-res", env: [String: String]?,
id: UUID().uuidString, timeoutMs: Int?) async -> ExecHostResponse
ok: false, {
payload: nil, let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed( await ShellExecutor.runDetailed(
command: command, command: command,
cwd: request.cwd, cwd: cwd,
env: env, env: env,
timeout: timeoutSec) timeout: timeoutSec)
}.value }.value
@@ -415,7 +479,24 @@ private enum ExecHostExecutor {
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
error: result.errorMessage) error: result.errorMessage)
return ExecHostResponse( return self.successResponse(payload)
}
private static func errorResponse(
code: String,
message: String,
reason: String?) -> ExecHostResponse
{
ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: code, message: message, reason: reason))
}
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
ExecHostResponse(
type: "exec-res", type: "exec-res",
id: UUID().uuidString, id: UUID().uuidString,
ok: true, ok: true,
@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case signal case signal
case imessage case imessage
case msteams case msteams
case bluebubbles
case webchat case webchat
init(raw: String?) { init(raw: String?) {
@@ -147,6 +148,27 @@ actor GatewayConnection {
} }
} }
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError throw lastError
case .remote: case .remote:
let nsError = error as NSError let nsError = error as NSError
@@ -243,9 +265,9 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String { private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "") let raw = defaults?[key]?.value as? String
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
} }
func cachedMainSessionKey() -> String? { func cachedMainSessionKey() -> String? {
@@ -155,7 +155,8 @@ actor GatewayEndpointStore {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty !configToken.isEmpty,
configToken != trimmed
{ {
self.warnEnvOverrideOnce( self.warnEnvOverrideOnce(
kind: .token, kind: .token,
@@ -164,32 +165,19 @@ actor GatewayEndpointStore {
} }
return trimmed return trimmed
} }
if isRemote {
if let gateway = root["gateway"] as? [String: Any], if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
let remote = gateway["remote"] as? [String: Any], !configToken.isEmpty
let token = remote["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{ {
let value = token.trimmingCharacters(in: .whitespacesAndNewlines) return configToken
if !value.isEmpty {
return value
}
} }
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
return token return token
} }
return nil return nil
} }
@@ -481,6 +469,35 @@ actor GatewayEndpointStore {
} }
} }
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
let mode = await self.deps.mode()
guard mode == .local else { return nil }
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: root,
env: ProcessInfo.processInfo.environment)
let port = self.deps.localPort()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
self.setState(.ready(mode: .local, url: url, token: token, password: password))
return (url, token, password)
}
private static func resolveGatewayBindMode( private static func resolveGatewayBindMode(
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String]) -> String?
@@ -536,8 +553,10 @@ actor GatewayEndpointStore {
tailscaleIP: String?) -> String tailscaleIP: String?) -> String
{ {
switch bindMode { switch bindMode {
case "tailnet", "auto": case "tailnet":
tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto":
"127.0.0.1"
case "custom": case "custom":
customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
@@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager {
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var plistURL: URL { private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
} }
@@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager {
extension GatewayLaunchAgentManager { extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool { private static func isLaunchAgentWriteDisabled() -> Bool {
let marker = FileManager.default.homeDirectoryForCurrentUser let marker = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker) .appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path) return FileManager().fileExists(atPath: marker.path)
} }
private static func readDaemonLoaded() async -> Bool? { private static func readDaemonLoaded() async -> Bool? {
@@ -365,7 +365,7 @@ final class GatewayProcessManager {
func clearLog() { func clearLog() {
self.log = "" self.log = ""
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared") self.logger.debug("gateway log cleared")
} }
@@ -378,7 +378,7 @@ final class GatewayProcessManager {
} }
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
guard FileManager.default.fileExists(atPath: path) else { return "" } guard FileManager().fileExists(atPath: path) else { return "" }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
let text = String(data: data, encoding: .utf8) ?? "" let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text } if text.count <= limit { return text }
@@ -3,17 +3,17 @@ import Foundation
enum LaunchAgentManager { enum LaunchAgentManager {
private static let legacyLaunchdLabel = "com.steipete.clawdbot" private static let legacyLaunchdLabel = "com.steipete.clawdbot"
private static var plistURL: URL { private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist") .appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
} }
private static var legacyPlistURL: URL { private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
} }
static func status() async -> Bool { static func status() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0 return result == 0
} }
@@ -21,7 +21,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async { static func set(enabled: Bool, bundlePath: String) async {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager().removeItem(at: self.legacyPlistURL)
self.writePlist(bundlePath: bundlePath) self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
@@ -29,7 +29,7 @@ enum LaunchAgentManager {
} else { } else {
// Disable autostart going forward but leave the current app running. // Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent). // bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager.default.removeItem(at: self.plistURL) try? FileManager().removeItem(at: self.plistURL)
} }
} }
@@ -46,7 +46,7 @@ enum LaunchAgentManager {
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string> <string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
</array> </array>
<key>WorkingDirectory</key> <key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string> <string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
<key>KeepAlive</key> <key>KeepAlive</key>
+2 -2
View File
@@ -18,7 +18,7 @@ enum LogLocator {
} }
private static func ensureLogDirExists() { private static func ensureLogDirExists() {
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true) try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true)
} }
private static func modificationDate(for url: URL) -> Date { private static func modificationDate(for url: URL) -> Date {
@@ -28,7 +28,7 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist. /// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? { static func bestLogFile() -> URL? {
self.ensureLogDirExists() self.ensureLogDirExists()
let fm = FileManager.default let fm = FileManager()
let files = (try? fm.contentsOfDirectory( let files = (try? fm.contentsOfDirectory(
at: self.logDir, at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey], includingPropertiesForKeys: [.contentModificationDateKey],
@@ -2,7 +2,7 @@ import Foundation
import JavaScriptCore import JavaScriptCore
enum ModelCatalogLoader { enum ModelCatalogLoader {
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
private static let logger = Logger(subsystem: "com.clawdbot", category: "models") private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
@@ -133,7 +133,7 @@ actor MacNodeRuntime {
let sessionKey = self.mainSessionKey let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil) let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) } defer { try? FileManager().removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path)) let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else { guard let image = NSImage(data: data) else {
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed") return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
@@ -206,7 +206,7 @@ actor MacNodeRuntime {
includeAudio: params.includeAudio ?? true, includeAudio: params.includeAudio ?? true,
deviceId: params.deviceId, deviceId: params.deviceId,
outPath: nil) outPath: nil)
defer { try? FileManager.default.removeItem(atPath: res.path) } defer { try? FileManager().removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ClipPayload: Encodable { struct ClipPayload: Encodable {
var format: String var format: String
@@ -312,7 +312,7 @@ actor MacNodeRuntime {
fps: params.fps, fps: params.fps,
includeAudio: params.includeAudio, includeAudio: params.includeAudio,
outPath: nil) outPath: nil)
defer { try? FileManager.default.removeItem(atPath: res.path) } defer { try? FileManager().removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable { struct ScreenPayload: Encodable {
var format: String var format: String
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {
let step: WizardStep let step: WizardStep
let isSubmitting: Bool let isSubmitting: Bool
let onSubmit: (AnyCodable?) -> Void let onStepSubmit: (AnyCodable?) -> Void
@State private var textValue: String @State private var textValue: String
@State private var confirmValue: Bool @State private var confirmValue: Bool
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
self.step = step self.step = step
self.isSubmitting = isSubmitting self.isSubmitting = isSubmitting
self.onSubmit = onSubmit self.onStepSubmit = onSubmit
let options = parseWizardOptions(step.options).enumerated().map { index, option in let options = parseWizardOptions(step.options).enumerated().map { index, option in
WizardOptionItem(index: index, option: option) WizardOptionItem(index: index, option: option)
} }
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
private func submit() { private func submit() {
switch wizardStepType(self.step) { switch wizardStepType(self.step) {
case "note", "progress": case "note", "progress":
self.onSubmit(nil) self.onStepSubmit(nil)
case "text": case "text":
self.onSubmit(AnyCodable(self.textValue)) self.onStepSubmit(AnyCodable(self.textValue))
case "confirm": case "confirm":
self.onSubmit(AnyCodable(self.confirmValue)) self.onStepSubmit(AnyCodable(self.confirmValue))
case "select": case "select":
guard self.optionItems.indices.contains(self.selectedIndex) else { guard self.optionItems.indices.contains(self.selectedIndex) else {
self.onSubmit(nil) self.onStepSubmit(nil)
return return
} }
let option = self.optionItems[self.selectedIndex].option let option = self.optionItems[self.selectedIndex].option
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect": case "multiselect":
let values = self.optionItems let values = self.optionItems
.filter { self.selectedIndices.contains($0.index) } .filter { self.selectedIndices.contains($0.index) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
self.onSubmit(AnyCodable(values)) self.onStepSubmit(AnyCodable(values))
case "action": case "action":
self.onSubmit(AnyCodable(true)) self.onStepSubmit(AnyCodable(true))
default: default:
self.onSubmit(nil) self.onStepSubmit(nil)
} }
} }
} }
@@ -24,7 +24,7 @@ actor PortGuardian {
private var records: [Record] = [] private var records: [Record] = []
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard") private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
private nonisolated static let appSupportDir: URL = { private nonisolated static let appSupportDir: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot", isDirectory: true) return base.appendingPathComponent("Clawdbot", isDirectory: true)
}() }()
@@ -71,7 +71,7 @@ actor PortGuardian {
} }
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
self.records.removeAll { $0.pid == pid } self.records.removeAll { $0.pid == pid }
self.records.append( self.records.append(
Record( Record(
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.11-4</string> <string>2026.1.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601113</string> <string>202601200</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -111,7 +111,7 @@ enum RuntimeLocator {
// MARK: - Internals // MARK: - Internals
private static func findExecutable(named name: String, searchPaths: [String]) -> String? { private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
let fm = FileManager.default let fm = FileManager()
for dir in searchPaths { for dir in searchPaths {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if fm.isExecutableFile(atPath: candidate) { if fm.isExecutableFile(atPath: candidate) {
@@ -42,10 +42,10 @@ final class ScreenRecordService {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath) return URL(fileURLWithPath: outPath)
} }
return FileManager.default.temporaryDirectory return FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
}() }()
try? FileManager.default.removeItem(at: outURL) try? FileManager().removeItem(at: outURL)
let content = try await SCShareableContent.current let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID } let displays = content.displays.sorted { $0.displayID < $1.displayID }
@@ -66,12 +66,12 @@ enum SessionActions {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent() let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl")) urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
} }
let home = FileManager.default.homeDirectoryForCurrentUser let home = FileManager().homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl")) urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
return urls return urls
}() }()
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) })
guard let url = existing else { guard let url = existing else {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Session log not found" alert.messageText = "Session log not found"
@@ -246,7 +246,7 @@ enum SessionLoader {
static let fallbackContextTokens = 200_000 static let fallbackContextTokens = 200_000
static let defaultStorePath = standardize( static let defaultStorePath = standardize(
FileManager.default.homeDirectoryForCurrentUser FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/sessions/sessions.json").path) .appendingPathComponent(".clawdbot/sessions/sessions.json").path)
static func loadSnapshot( static func loadSnapshot(
@@ -44,7 +44,7 @@ enum SoundEffectCatalog {
] ]
private static let searchRoots: [URL] = [ private static let searchRoots: [URL] = [
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
URL(fileURLWithPath: "/Library/Sounds"), URL(fileURLWithPath: "/Library/Sounds"),
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
URL(fileURLWithPath: "/System/Library/Sounds"), URL(fileURLWithPath: "/System/Library/Sounds"),
@@ -53,7 +53,7 @@ enum SoundEffectCatalog {
private static let discoveredSoundMap: [String: URL] = { private static let discoveredSoundMap: [String: URL] = {
var map: [String: URL] = [:] var map: [String: URL] = [:]
for root in Self.searchRoots { for root in Self.searchRoots {
guard let contents = try? FileManager.default.contentsOfDirectory( guard let contents = try? FileManager().contentsOfDirectory(
at: root, at: root,
includingPropertiesForKeys: nil, includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]) options: [.skipsHiddenFiles])
@@ -53,7 +53,7 @@ final class TailscaleService {
#endif #endif
func checkAppInstallation() -> Bool { func checkAppInstallation() -> Bool {
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app") let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app")
self.logger.info("Tailscale app installed: \(installed)") self.logger.info("Tailscale app installed: \(installed)")
return installed return installed
} }
@@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
} }
private func startMonitoring() { private func startMonitoring() {
assert(Thread.isMainThread) // assert(Thread.isMainThread) - Removed for Swift 6
guard self.globalMonitor == nil, self.localMonitor == nil else { return } guard self.globalMonitor == nil, self.localMonitor == nil else { return }
// Listen-only global monitor; we rely on Input Monitoring permission to receive events. // Listen-only global monitor; we rely on Input Monitoring permission to receive events.
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
@@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
} }
private func stopMonitoring() { private func stopMonitoring() {
assert(Thread.isMainThread) // assert(Thread.isMainThread) - Removed for Swift 6
if let globalMonitor { if let globalMonitor {
NSEvent.removeMonitor(globalMonitor) NSEvent.removeMonitor(globalMonitor)
self.globalMonitor = nil self.globalMonitor = nil
@@ -75,15 +75,11 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
} }
private func withMainThread(_ block: @escaping @Sendable () -> Void) { private func withMainThread(_ block: @escaping @Sendable () -> Void) {
if Thread.isMainThread { DispatchQueue.main.async(execute: block)
block()
} else {
DispatchQueue.main.async(execute: block)
}
} }
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
assert(Thread.isMainThread) // assert(Thread.isMainThread) - Removed for Swift 6
// Right Option (keyCode 61) acts as a hold-to-talk modifier. // Right Option (keyCode 61) acts as a hold-to-talk modifier.
if keyCode == 61 { if keyCode == 61 {
self.optionDown = modifierFlags.contains(.option) self.optionDown = modifierFlags.contains(.option)
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)" let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig( guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines) let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue } if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)" let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix) let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count)) ? String(ptrName.dropLast(suffix.count))
: ptrName : ptrName
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
{ {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)" let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
let ips = candidates let ips = candidates
candidates.removeAll(keepingCapacity: true) candidates.removeAll(keepingCapacity: true)
@@ -1,150 +0,0 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}
+4 -5
View File
@@ -408,8 +408,7 @@ extension Request: Codable {
} }
// Shared transport settings // Shared transport settings
public let controlSocketPath = public let controlSocketPath = FileManager()
FileManager.default .homeDirectoryForCurrentUser
.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.appendingPathComponent("Library/Application Support/clawdbot/control.sock") .path
.path
@@ -0,0 +1,306 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15_000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
var clientMode: String = "ui"
var displayName: String?
var role: String = "operator"
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
var help: Bool = false
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--probe":
opts.probe = true
case "--url":
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
}
i += 1
}
return opts
}
private static func nextValue(_ args: [String], index: inout Int) -> String? {
guard index + 1 < args.count else { return nil }
index += 1
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
struct ConnectOutput: Encodable {
var status: String
var url: String
var mode: String
var role: String
var clientId: String
var clientMode: String
var scopes: [String]
var snapshot: HelloOk?
var health: ProtoAnyCodable?
var error: String?
}
actor SnapshotStore {
private var value: HelloOk?
func set(_ snapshot: HelloOk) {
self.value = snapshot
}
func get() -> HelloOk? {
self.value
}
}
func runConnect(_ args: [String]) async {
let opts = ConnectOptions.parse(args)
if opts.help {
print("""
clawdbot-mac connect
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Resolve from config: local|remote (default: config or local)
--timeout <ms> Request timeout (default: 15000)
--probe Force a fresh health probe
--json Emit JSON
--client-id <id> Override client id (default: clawdbot-macos)
--client-mode <m> Override client mode (default: ui)
--display-name <n> Override display name
--role <role> Override role (default: operator)
--scopes <a,b,c> Override scopes list
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
let connectOptions = GatewayConnectOptions(
role: opts.role,
scopes: opts.scopes,
caps: [],
commands: [],
permissions: [:],
clientId: opts.clientId,
clientMode: opts.clientMode,
clientDisplayName: displayName)
let snapshotStore = SnapshotStore()
let channel = GatewayChannelActor(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
pushHandler: { push in
if case let .snapshot(ok) = push {
await snapshotStore.set(ok)
}
},
connectOptions: connectOptions)
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
let data = try await channel.request(
method: "health",
params: params,
timeoutMs: Double(opts.timeoutMs))
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
let snapshot = await snapshotStore.get()
await channel.shutdown()
let output = ConnectOutput(
status: "ok",
url: endpoint.url.absoluteString,
mode: endpoint.mode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: snapshot,
health: health,
error: nil)
printConnectOutput(output, json: opts.json)
} catch {
let endpoint = bestEffortEndpoint(opts: opts, config: config)
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
let output = ConnectOutput(
status: "error",
url: endpoint?.url.absoluteString ?? "unknown",
mode: endpoint?.mode ?? fallbackMode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: nil,
health: nil,
error: error.localizedDescription)
printConnectOutput(output, json: opts.json)
exit(1)
}
}
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(output),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Clawdbot macOS Gateway Connect")
print("Status: \(output.status)")
print("URL: \(output.url)")
print("Mode: \(output.mode)")
print("Client: \(output.clientId) (\(output.clientMode))")
print("Role: \(output.role)")
print("Scopes: \(output.scopes.joined(separator: ", "))")
if let snapshot = output.snapshot {
print("Protocol: \(snapshot._protocol)")
if let version = snapshot.server["version"]?.value as? String {
print("Server: \(version)")
}
}
if let health = output.health,
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
{
print("Health: \(ok ? "ok" : "error")")
} else if output.health != nil {
print("Health: received")
}
if let error = output.error {
print("Error: \(error)")
}
}
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
let port = config.port ?? 18789
let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
return try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
return token
}
if mode == "remote" {
return config.remoteToken
}
return config.token
}
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let password = opts.password, !password.isEmpty { return password }
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
return password
}
if mode == "remote" {
return config.remotePassword
}
return config.password
}
@@ -0,0 +1,149 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
func runDiscover(_ args: [String]) async {
let opts = DiscoveryOptions.parse(args)
if opts.help {
print("""
clawdbot-mac discover
Usage:
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = await MainActor.run {
GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
}
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
@@ -0,0 +1,56 @@
import Foundation
private struct RootCommand {
var name: String
var args: [String]
}
@main
struct ClawdbotMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
let command = parseRootCommand(args)
switch command?.name {
case nil:
printUsage()
case "-h", "--help", "help":
printUsage()
case "connect":
await runConnect(command?.args ?? [])
case "discover":
await runDiscover(command?.args ?? [])
case "wizard":
await runWizardCommand(command?.args ?? [])
default:
fputs("clawdbot-mac: unknown command\n", stderr)
printUsage()
exit(1)
}
}
}
private func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
private func printUsage() {
print("""
clawdbot-mac
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Examples:
clawdbot-mac connect
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
clawdbot-mac discover --timeout 3000 --json
clawdbot-mac wizard --mode local
""")
}
@@ -0,0 +1,60 @@
import Foundation
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
let mode: String
}
func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
@@ -0,0 +1,5 @@
import ClawdbotKit
import ClawdbotProtocol
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
typealias KitAnyCodable = ClawdbotKit.AnyCodable
@@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Darwin import Darwin
import Foundation import Foundation
@@ -48,17 +49,6 @@ struct WizardCliOptions {
} }
} }
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
enum WizardCliError: Error, CustomStringConvertible { enum WizardCliError: Error, CustomStringConvertible {
case invalidUrl(String) case invalidUrl(String)
case missingRemoteUrl case missingRemoteUrl
@@ -77,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible {
} }
} }
@main func runWizardCommand(_ args: [String]) async {
struct ClawdbotWizardCLI { let opts = WizardCliOptions.parse(args)
static func main() async { if opts.help {
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst())) print("""
if opts.help { clawdbot-mac wizard
printUsage()
return
}
let config = loadGatewayConfig() Usage:
do { clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
guard isatty(STDIN_FILENO) != 0 else { [--mode <local|remote>] [--workspace <path>] [--json]
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
} Options:
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config) --url <url> Gateway WebSocket URL (overrides config)
let client = GatewayWizardClient( --token <token> Gateway token (if required)
url: endpoint.url, --password <pw> Gateway password (if required)
token: endpoint.token, --mode <mode> Wizard mode (local|remote). Default: local
password: endpoint.password, --workspace <path> Wizard workspace override
json: opts.json) --json Print raw wizard responses
try await client.connect() -h, --help Show help
defer { Task { await client.close() } } """)
try await runWizard(client: client, opts: opts) return
} catch { }
fputs("wizard: \(error)\n", stderr)
exit(1) let config = loadGatewayConfig()
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
} }
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
} }
} }
private struct GatewayEndpoint { private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let url: URL
let token: String?
let password: String?
}
private func printUsage() {
print("""
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
}
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
if let raw = opts.url, !raw.isEmpty { if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config)) password: resolvedPassword(opts: opts, config: config),
mode: (config.mode ?? "local").lowercased())
} }
let mode = (config.mode ?? "local").lowercased() let mode = (config.mode ?? "local").lowercased()
@@ -150,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config)) password: resolvedPassword(opts: opts, config: config),
mode: mode)
} }
let port = config.port ?? 18789 let port = config.port ?? 18789
@@ -161,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config)) password: resolvedPassword(opts: opts, config: config),
mode: mode)
} }
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? { private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
@@ -186,48 +166,11 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
return config.password return config.password
} }
private func loadGatewayConfig() -> GatewayConfig {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
private func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
actor GatewayWizardClient { actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case timeout
}
private let url: URL private let url: URL
private let token: String? private let token: String?
private let password: String? private let password: String?
@@ -235,6 +178,7 @@ actor GatewayWizardClient {
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let session = URLSession(configuration: .default) private let session = URLSession(configuration: .default)
private let connectChallengeTimeoutSeconds: Double = 0.75
private var task: URLSessionWebSocketTask? private var task: URLSessionWebSocketTask?
init(url: URL, token: String?, password: String?, json: Bool) { init(url: URL, token: String?, password: String?, json: Bool) {
@@ -257,7 +201,7 @@ actor GatewayWizardClient {
self.task = nil self.task = nil
} }
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame { func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
guard let task = self.task else { guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected") throw WizardCliError.gatewayError("gateway not connected")
} }
@@ -266,7 +210,7 @@ actor GatewayWizardClient {
type: "req", type: "req",
id: id, id: id,
method: method, method: method,
params: params.map { AnyCodable($0) }) params: params.map { ProtoAnyCodable($0) })
let data = try self.encoder.encode(frame) let data = try self.encoder.encode(frame)
try await task.send(.data(data)) try await task.send(.data(data))
@@ -309,28 +253,66 @@ actor GatewayWizardClient {
} }
let osVersion = ProcessInfo.processInfo.operatingSystemVersion let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let client: [String: AnyCodable] = [ let clientId = "clawdbot-macos"
"id": AnyCodable("clawdbot-macos"), let clientMode = "ui"
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"), let role = "operator"
"version": AnyCodable("dev"), let scopes: [String] = []
"platform": AnyCodable(platform), let client: [String: ProtoAnyCodable] = [
"deviceFamily": AnyCodable("Mac"), "id": ProtoAnyCodable(clientId),
"mode": AnyCodable("ui"), "displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"instanceId": AnyCodable(UUID().uuidString), "version": ProtoAnyCodable("dev"),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
] ]
var params: [String: AnyCodable] = [ var params: [String: ProtoAnyCodable] = [
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION), "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": AnyCodable(client), "client": ProtoAnyCodable(client),
"caps": AnyCodable([String]()), "caps": ProtoAnyCodable([String]()),
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), "locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
] ]
if let token = self.token { if let token = self.token {
params["auth"] = AnyCodable(["token": AnyCodable(token)]) params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password { } else if let password = self.password {
params["auth"] = AnyCodable(["password": AnyCodable(password)]) params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
} }
let reqId = UUID().uuidString let reqId = UUID().uuidString
@@ -338,31 +320,58 @@ actor GatewayWizardClient {
type: "req", type: "req",
id: reqId, id: reqId,
method: "connect", method: "connect",
params: AnyCodable(params)) params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame) let data = try self.encoder.encode(frame)
try await task.send(.data(data)) try await task.send(.data(data))
let message = try await task.receive() while true {
let frameResponse = try decodeFrame(message) let message = try await task.receive()
guard case let .res(res) = frameResponse, res.id == reqId else { let frameResponse = try decodeFrame(message)
throw WizardCliError.gatewayError("connect failed (unexpected response)") if case let .res(res) = frameResponse, res.id == reqId {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
return
}
} }
if res.ok == false { }
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg) private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String
{
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
} }
_ = try self.decodePayload(res, as: HelloOk.self)
} }
} }
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws { private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: AnyCodable] = [:] var params: [String: ProtoAnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" { if mode == "local" || mode == "remote" {
params["mode"] = AnyCodable(mode) params["mode"] = ProtoAnyCodable(mode)
} }
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty { if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = AnyCodable(workspace) params["workspace"] = ProtoAnyCodable(workspace)
} }
let startResponse = try await client.request(method: "wizard.start", params: params) let startResponse = try await client.request(method: "wizard.start", params: params)
@@ -395,17 +404,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
if let step = decodeWizardStep(nextResult.step) { if let step = decodeWizardStep(nextResult.step) {
let answer = try promptAnswer(for: step) let answer = try promptAnswer(for: step)
var answerPayload: [String: AnyCodable] = [ var answerPayload: [String: ProtoAnyCodable] = [
"stepId": AnyCodable(step.id), "stepId": ProtoAnyCodable(step.id),
] ]
if !(answer is NSNull) { if !(answer is NSNull) {
answerPayload["value"] = AnyCodable(answer) answerPayload["value"] = ProtoAnyCodable(answer)
} }
let response = try await client.request( let response = try await client.request(
method: "wizard.next", method: "wizard.next",
params: [ params: [
"sessionId": AnyCodable(sessionId), "sessionId": ProtoAnyCodable(sessionId),
"answer": AnyCodable(answerPayload), "answer": ProtoAnyCodable(answerPayload),
]) ])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self) nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json { if opts.json {
@@ -414,7 +423,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} else { } else {
let response = try await client.request( let response = try await client.request(
method: "wizard.next", method: "wizard.next",
params: ["sessionId": AnyCodable(sessionId)]) params: ["sessionId": ProtoAnyCodable(sessionId)])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self) nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json { if opts.json {
dumpResult(response) dumpResult(response)
@@ -424,7 +433,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} catch WizardCliError.cancelled { } catch WizardCliError.cancelled {
_ = try? await client.request( _ = try? await client.request(
method: "wizard.cancel", method: "wizard.cancel",
params: ["sessionId": AnyCodable(sessionId)]) params: ["sessionId": ProtoAnyCodable(sessionId)])
throw WizardCliError.cancelled throw WizardCliError.cancelled
} }
} }
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,7 @@ import Testing
struct AgentWorkspaceTests { struct AgentWorkspaceTests {
@Test @Test
func displayPathUsesTildeForHome() { func displayPathUsesTildeForHome() {
let home = FileManager.default.homeDirectoryForCurrentUser let home = FileManager().homeDirectoryForCurrentUser
#expect(AgentWorkspace.displayPath(for: home) == "~") #expect(AgentWorkspace.displayPath(for: home) == "~")
let inside = home.appendingPathComponent("Projects", isDirectory: true) let inside = home.appendingPathComponent("Projects", isDirectory: true)
@@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapCreatesAgentsFileWhenMissing() throws { func bootstrapCreatesAgentsFileWhenMissing() throws {
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp) let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(FileManager.default.fileExists(atPath: agentsURL.path)) #expect(FileManager().fileExists(atPath: agentsURL.path))
let contents = try String(contentsOf: agentsURL, encoding: .utf8) let contents = try String(contentsOf: agentsURL, encoding: .utf8)
#expect(contents.contains("# AGENTS.md")) #expect(contents.contains("# AGENTS.md"))
@@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(FileManager.default.fileExists(atPath: identityURL.path)) #expect(FileManager().fileExists(atPath: identityURL.path))
#expect(FileManager.default.fileExists(atPath: userURL.path)) #expect(FileManager().fileExists(atPath: userURL.path))
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path)) #expect(FileManager().fileExists(atPath: bootstrapURL.path))
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(second == agentsURL) #expect(second == agentsURL)
@@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt") let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8) try "hello".write(to: marker, atomically: true, encoding: .utf8)
@@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapSafetyAllowsExistingAgentsFile() throws { func bootstrapSafetyAllowsExistingAgentsFile() throws {
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename) let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
@@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt") let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8) try "hello".write(to: marker, atomically: true, encoding: .utf8)
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp) _ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path)) #expect(!FileManager().fileExists(atPath: bootstrapURL.path))
} }
@Test @Test
func needsBootstrapFalseWhenIdentityAlreadySet() throws { func needsBootstrapFalseWhenIdentityAlreadySet() throws {
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
try """ try """
# IDENTITY.md - Agent Identity # IDENTITY.md - Agent Identity
@@ -6,9 +6,9 @@ import Testing
struct AnthropicAuthResolverTests { struct AnthropicAuthResolverTests {
@Test @Test
func prefersOAuthFileOverEnv() throws { func prefersOAuthFileOverEnv() throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let oauthFile = dir.appendingPathComponent("oauth.json") let oauthFile = dir.appendingPathComponent("oauth.json")
let payload = [ let payload = [
"anthropic": [ "anthropic": [
@@ -6,7 +6,7 @@ import Testing
@MainActor @MainActor
struct CLIInstallerTests { struct CLIInstallerTests {
@Test func installedLocationFindsExecutable() throws { @Test func installedLocationFindsExecutable() throws {
let fm = FileManager.default let fm = FileManager()
let root = fm.temporaryDirectory.appendingPathComponent( let root = fm.temporaryDirectory.appendingPathComponent(
"clawdbot-cli-installer-\(UUID().uuidString)") "clawdbot-cli-installer-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) } defer { try? fm.removeItem(at: root) }
@@ -7,13 +7,13 @@ import Testing
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true) let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir return dir
} }
@Test func detectsInPlaceFileWrites() async throws { @Test func detectsInPlaceFileWrites() async throws {
let dir = try self.makeTempDir() let dir = try self.makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager().removeItem(at: dir) }
let file = dir.appendingPathComponent("index.html") let file = dir.appendingPathComponent("index.html")
try "hello".write(to: file, atomically: false, encoding: .utf8) try "hello".write(to: file, atomically: false, encoding: .utf8)
@@ -8,10 +8,10 @@ import Testing
@MainActor @MainActor
struct CanvasWindowSmokeTests { struct CanvasWindowSmokeTests {
@Test func panelControllerShowsAndHides() async throws { @Test func panelControllerShowsAndHides() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)") .appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager().removeItem(at: root) }
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
let controller = try CanvasWindowController( let controller = try CanvasWindowController(
@@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests {
} }
@Test func windowControllerShowsAndCloses() async throws { @Test func windowControllerShowsAndCloses() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)") .appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager().removeItem(at: root) }
let controller = try CanvasWindowController( let controller = try CanvasWindowController(
sessionKey: "main", sessionKey: "main",
@@ -6,7 +6,7 @@ import Testing
struct ClawdbotConfigFileTests { struct ClawdbotConfigFileTests {
@Test @Test
func configPathRespectsEnvOverride() async { func configPathRespectsEnvOverride() async {
let override = FileManager.default.temporaryDirectory let override = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
@@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests {
@MainActor @MainActor
@Test @Test
func remoteGatewayPortParsesAndMatchesHost() async { func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager.default.temporaryDirectory let override = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
@@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests {
@MainActor @MainActor
@Test @Test
func setRemoteGatewayUrlPreservesScheme() async { func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager.default.temporaryDirectory let override = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
@@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests {
@Test @Test
func stateDirOverrideSetsConfigPath() async { func stateDirOverrideSetsConfigPath() async {
let dir = FileManager.default.temporaryDirectory let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path .path
@@ -6,7 +6,7 @@ import Testing
struct ClawdbotOAuthStoreTests { struct ClawdbotOAuthStoreTests {
@Test @Test
func returnsMissingWhenFileAbsent() { func returnsMissingWhenFileAbsent() {
let url = FileManager.default.temporaryDirectory let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)") .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json") .appendingPathComponent("oauth.json")
#expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) #expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
@@ -24,7 +24,7 @@ struct ClawdbotOAuthStoreTests {
} }
} }
let dir = FileManager.default.temporaryDirectory let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1) setenv(key, dir.path, 1)
@@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests {
} }
private func writeOAuthFile(_ json: [String: Any]) throws -> URL { private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("oauth.json") let url = dir.appendingPathComponent("oauth.json")
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
@@ -12,16 +12,16 @@ import Testing
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir return dir
} }
private func makeExec(at path: URL) throws { private func makeExec(at path: URL) throws {
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: path.deletingLastPathComponent(), at: path.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
} }
@Test func prefersClawdbotBinary() async throws { @Test func prefersClawdbotBinary() async throws {
@@ -49,7 +49,7 @@ import Testing
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js") let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
try self.makeExec(at: nodePath) try self.makeExec(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try self.makeExec(at: scriptPath) try self.makeExec(at: scriptPath)
let cmd = CommandResolver.clawdbotCommand( let cmd = CommandResolver.clawdbotCommand(
@@ -32,7 +32,7 @@ import Testing
} }
private static func swiftFiles(under root: URL) throws -> [URL] { private static func swiftFiles(under root: URL) throws -> [URL] {
let fm = FileManager.default let fm = FileManager()
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
return [] return []
} }
@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true) #expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false) #expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
} }
@@ -18,6 +19,7 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last) #expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last) #expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last) #expect(GatewayAgentChannel(raw: "unknown") == .last)
} }
} }
@@ -4,7 +4,7 @@ import Testing
@Suite struct GatewayLaunchAgentManagerTests { @Suite struct GatewayLaunchAgentManagerTests {
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws { @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let url = FileManager.default.temporaryDirectory let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [ let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"], "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
@@ -15,7 +15,7 @@ import Testing
] ]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) } defer { try? FileManager().removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789) #expect(snapshot.port == 18789)
@@ -25,14 +25,14 @@ import Testing
} }
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws { @Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
let url = FileManager.default.temporaryDirectory let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [ let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"], "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
] ]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) } defer { try? FileManager().removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789) #expect(snapshot.port == 18789)
@@ -5,7 +5,7 @@ import Testing
@Suite struct LogLocatorTests { @Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws { @Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager.default let fm = FileManager()
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)") let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
@@ -77,9 +77,9 @@ struct LowCoverageHelperTests {
} }
@Test func pairedNodesStorePersists() async throws { @Test func pairedNodesStorePersists() async throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json") let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url) let store = PairedNodesStore(fileURL: url)
await store.load() await store.load()
@@ -143,12 +143,12 @@ struct LowCoverageHelperTests {
} }
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws { @Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
let root = FileManager.default.temporaryDirectory let root = FileManager().temporaryDirectory
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager().removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
let session = root.appendingPathComponent("main", isDirectory: true) let session = root.appendingPathComponent("main", isDirectory: true)
try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
let index = session.appendingPathComponent("index.html") let index = session.appendingPathComponent("index.html")
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8) try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
@@ -59,7 +59,7 @@ struct MacNodeRuntimeTests {
includeAudio: Bool?, includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool) outPath: String?) async throws -> (path: String, hasAudio: Bool)
{ {
let url = FileManager.default.temporaryDirectory let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url) try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false) return (path: url.path, hasAudio: false)
@@ -19,9 +19,9 @@ struct ModelCatalogLoaderTests {
}; };
""" """
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts") .appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8) try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path) let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests {
@Test @Test
func loadWithNoExportReturnsEmptyChoices() async throws { func loadWithNoExportReturnsEmptyChoices() async throws {
let src = "const NOPE = 1;" let src = "const NOPE = 1;"
let tmp = FileManager.default.temporaryDirectory let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts") .appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager.default.removeItem(at: tmp) } defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8) try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path) let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -6,16 +6,16 @@ import Testing
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir return dir
} }
private func makeExec(at path: URL) throws { private func makeExec(at path: URL) throws {
try FileManager.default.createDirectory( try FileManager().createDirectory(
at: path.deletingLastPathComponent(), at: path.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
} }
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws { @Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
@@ -37,7 +37,7 @@ import Testing
let home = try self.makeTempDir() let home = try self.makeTempDir()
let missingNodeBin = home let missingNodeBin = home
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin") .appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
try FileManager.default.createDirectory(at: missingNodeBin, withIntermediateDirectories: true) try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
let bins = CommandResolver._testNodeManagerBinPaths(home: home) let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(!bins.contains(missingNodeBin.path)) #expect(!bins.contains(missingNodeBin.path))
Loaded 100 of 818 files, more files were not shown because too many files have changed in this diff. Show more