Compare commits

..

157 Commits

Author SHA1 Message Date
quotentiroler
2519626293 test: skip unix-path OPENCLAW_HOME tests on Windows 2026-02-08 16:52:52 -08:00
quotentiroler
783f855e79 test: skip unix-path Nix tests on Windows 2026-02-08 15:33:22 -08:00
quotentiroler
6453fdbf70 feat: backward compat for resetByType.dm config key 2026-02-08 15:21:36 -08:00
quotentiroler
93963603b4 test: add explicit backward compat tests for dmdirect migration
- session-key.test.ts: verify both :dm: and :direct: keys are valid
- getDmHistoryLimitFromSessionKey: verify both formats work
2026-02-08 15:04:55 -08:00
max
aced998b0b Merge branch 'main' into fix/import-extensions 2026-02-08 14:56:43 -08:00
George Pickett
0b07e15b63 chore(changelog): note maxTokens clamp (#5516) (thanks @lailoo) (#12139) 2026-02-08 14:27:11 -08:00
George Pickett
92764a60d6 test(config): cover maxTokens clamping 2026-02-08 14:24:57 -08:00
damaozi
eed580d310 fix(config): clamp maxTokens to contextWindow to prevent invalid configurations
Closes #5308

When users configure maxTokens larger than contextWindow (e.g., maxTokens: 40960
with contextWindow: 32768), the model may fail silently. This fix clamps
maxTokens to be at most contextWindow, preventing such invalid configurations.
2026-02-08 14:24:57 -08:00
Sebastian
41f3e90ea8 changelog: split #12091 entry into Added + Fixes 2026-02-08 16:21:18 -05:00
Seb Slight
db137dd65d fix(paths): respect OPENCLAW_HOME for all internal path resolution (#12091)
* fix(paths): respect OPENCLAW_HOME for all internal path resolution (#11995)

Add home-dir module (src/infra/home-dir.ts) that centralizes home
directory resolution with precedence: OPENCLAW_HOME > HOME > USERPROFILE > os.homedir().

Migrate all path-sensitive callsites: config IO, agent dirs, session
transcripts, pairing store, cron store, doctor, CLI profiles.

Add envHomedir() helper in config/paths.ts to reduce lambda noise.
Document OPENCLAW_HOME in docs/help/environment.md.

* fix(paths): handle OPENCLAW_HOME '~' fallback (#12091) (thanks @sebslight)

* docs: mention OPENCLAW_HOME in install and getting started (#12091) (thanks @sebslight)

* fix(status): show OPENCLAW_HOME in shortened paths (#12091) (thanks @sebslight)

* docs(changelog): clarify OPENCLAW_HOME and HOME precedence (#12091) (thanks @sebslight)
2026-02-08 16:20:13 -05:00
Josh Palmer
c95e6fe6dc Docs: note language switcher ordering + JP flag fix (#12023) (thanks @joshp123) 2026-02-08 10:45:44 -08:00
Josh Palmer
2b4135debc Docs: fix language switcher order + Japanese locale 2026-02-08 10:45:44 -08:00
quotentiroler
56ca8f2eab fix: accept legacy 'dm' in session key parsing for backward compat
getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct:
to ensure old session keys continue to work correctly.
2026-02-08 10:44:18 -08:00
Josh Palmer
6e3271ebb6 Docs: note ja-JP docs POC in changelog (#11988) (thanks @joshp123) 2026-02-08 10:18:04 -08:00
Josh Palmer
d8dbfc701c Docs: use ja-jp Mintlify language code 2026-02-08 10:18:04 -08:00
Josh Palmer
c4213b89eb Docs: seed ja-JP translations 2026-02-08 10:18:04 -08:00
Josh Palmer
d2ec78607d Docs i18n: make translation prompt language-pluggable 2026-02-08 10:18:04 -08:00
quotentiroler
4e7392cb36 test: update session key expectations for dmdirect migration
- Fix test expectations to expect :direct: in generated output
- Add explicit backward compat test for normalizeChatType('dm')
- Keep input test data with :dm: keys to verify backward compat
2026-02-08 09:49:30 -08:00
quotentiroler
6f97b9d76f fix tests 2026-02-08 09:44:52 -08:00
quotentiroler
20ae9470ea refactor: unify peer kind to ChatType, rename dm to direct
- Replace RoutePeerKind with ChatType throughout codebase
- Change 'dm' literal values to 'direct' in routing/session keys
- Keep backward compat: normalizeChatType accepts 'dm' -> 'direct'
- Add ChatType export to plugin-sdk, deprecate RoutePeerKind
- Update session key parsing to accept both 'dm' and 'direct' markers
- Update all channel monitors and extensions to use ChatType

BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'.
Existing 'dm' keys still work via backward compat layer.
2026-02-08 09:35:35 -08:00
Vignesh Natarajan
7f7d49aef0 Memory/QMD: warn when scope denies search 2026-02-08 09:21:17 -08:00
Mariano Belinky
6aedc54bd7 iOS: alpha node app + setup-code onboarding (#11756) 2026-02-08 18:08:13 +01:00
Mariano Belinky
730f86dd5c Gateway/Plugins: device pairing + phone control plugins (#11755) 2026-02-08 18:07:13 +01:00
quotentiroler
0ddc5c0b8b Merge branch 'fix/import-extensions' of https://github.com/openclaw/openclaw into fix/import-extensions 2026-02-08 08:47:39 -08:00
quotentiroler
21cf7c1453 fix tsconfig 2026-02-08 08:47:25 -08:00
max
d46c0b712e Merge branch 'main' into fix/import-extensions 2026-02-08 08:45:55 -08:00
Seb Slight
2f91bf550f docs: fix changelog PR reference
Fix Exec approvals command text formatting issue for safer approval scanning.
2026-02-08 10:50:10 -05:00
Seb Slight
ad8b839aa7 Exec approvals: render forwarded commands in monospace (#11937)
* fix(exec-approvals): format forwarded commands as code

* fix(exec-approvals): place fenced command blocks on new line (#11937) (thanks @sebslight)
2026-02-08 10:48:52 -05:00
seans-openclawbot
744892de72 Add GitHub Copilot models to xhigh list (#11646)
* Add GitHub Copilot models to xhigh list

* fix(thinking): add xhigh copilot tests and changelog (#11646) (thanks @seans-openclawbot)

---------

Co-authored-by: Sean Dai <sdai@gatech.edu>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-08 08:45:59 -05:00
quotentiroler
cb24ec64f2 fix: use .js extension for ESM imports of RoutePeerKind
The imports incorrectly used .ts extension which doesn't resolve
with moduleResolution: NodeNext. Changed to .js and added 'type'
import modifier.
2026-02-08 05:35:04 -08:00
max
eb3e9c649b chore: fix vitest standalone configs and update package description (#11865)
* chore: fix vitest standalone configs and update package description

- vitest.live.config.ts and vitest.e2e.config.ts now extend root config
- Inherits testTimeout (120s), resolve.alias, pool, setupFiles, excludes
- ui/vitest.node.config.ts gets explicit 120s timeout
- package.json description updated for multi-channel AI gateway
- Removed unused src/utils/time-format.ts

* chore: filter inherited excludes in live/e2e vitest configs

* refactor: dedupe GroupPolicy/DmPolicy in extensions

Import from openclaw/plugin-sdk instead of re-declaring identical types.
2026-02-08 05:24:50 -08:00
max
a1123dd9be Centralize date/time formatting utilities (#11831) 2026-02-08 04:53:31 -08:00
theonejvo
74fbbda283 docs: add security & trust documentation
Add threat model (MITRE ATLAS), contribution guide, and security
directory README. Update SECURITY.md with trust page reporting
instructions and Jamieson O'Reilly as Security & Trust.

Co-Authored-By: theonejvo <theonejvo@users.noreply.github.com>
2026-02-08 21:53:05 +11:00
max
28e1a65ebc chore: project hygiene — fix workspace:*, sandbox USER, dead config (#11289)
* chore: project hygiene fixes (workspace:*, sandbox USER, dead config)

* chore: also fix workspace:* in zalouser dependencies
2026-02-08 02:36:42 -08:00
Gustavo Madeira Santana
c56fb7f353 chore: suppress warnings for node default output path 2026-02-08 05:32:58 -05:00
Gustavo Madeira Santana
3119057161 chore: centralizing warning filters 2026-02-08 05:18:08 -05:00
Gustavo Madeira Santana
cef9bfce22 CI: scope heavy jobs, build once, and remove duplicate validation work (#11570)
* CI: scope jobs and reuse build artifacts

* CI: fix scope fallback and remove unused artifact job

* CI: remove setup-node pnpm cache inputs

* CI: add pnpm store cache and dist artifact smoke

* CI: extract pnpm cache action and consume dist artifact
2026-02-08 02:08:56 -08:00
Gustavo Madeira Santana
b75d618080 fix(doctor): suppress repeated legacy state migration warnings (#11709)
* fix(doctor): suppress repeated state migration warning

* fix: harden state-dir mirror detection + warnings (#11709) (thanks @gumadeiras)

* test: cover mirror hardening edge cases (#11709) (thanks @gumadeiras)
2026-02-08 02:27:49 -05:00
ezhikkk
e02d144af9 feat(telegram): add spoiler tag support (#11543)
* feat(telegram): add spoiler tag support

Render markdown ||spoiler|| syntax as <tg-spoiler> tags in Telegram HTML output.

The markdown IR already parses spoiler syntax, but the Telegram renderer was
missing the style marker. This adds the spoiler marker to renderTelegramHtml().

Fixes spoiler text appearing as raw ||text|| instead of hidden text.

* fix: enable Telegram spoiler rendering (#11543) (thanks @ezhikkk)

---------

Co-authored-by: Параша <parasha@openclaw.local>
Co-authored-by: Muhammed Mukhthar CM <mukhtharcm@gmail.com>
2026-02-08 11:25:56 +05:30
jarvis89757
9949f82590 fix(discord): support forum channel thread-create (#10062)
* fix(discord): support forum channel thread-create

* fix: harden discord forum thread-create (#10062) (thanks @jarvis89757)

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
2026-02-08 05:51:10 +00:00
Tyler Yust
bc475f0172 fix(ui): smooth chat refresh scroll and suppress new-messages badge flash 2026-02-07 20:21:27 -08:00
Tyler Yust
191da1feb5 fix: context overflow compaction and subagent announce improvements (#11664) (thanks @tyler6204)
* initial commit

* feat: implement deriveSessionTotalTokens function and update usage tests

* Added deriveSessionTotalTokens function to calculate total tokens based on usage and context tokens.
* Updated usage tests to include cases for derived session total tokens.
* Refactored session usage calculations in multiple files to utilize the new function for improved accuracy.

* fix: restore overflow truncation fallback + changelog/test hardening (#11551) (thanks @tyler6204)
2026-02-07 20:02:32 -08:00
Tyler Yust
8fae55e8e0 fix(cron): share isolated announce flow + harden cron scheduling/delivery (#11641)
* fix(cron): comprehensive cron scheduling and delivery fixes

- Fix delivery target resolution for isolated agent cron jobs
- Improve schedule parsing and validation
- Add job retry logic and error handling
- Enhance cron ops with better state management
- Add timer improvements for more reliable cron execution
- Add cron event type to protocol schema
- Support cron events in heartbeat runner (skip empty-heartbeat check,
  use dedicated CRON_EVENT_PROMPT for relay)

* fix: remove cron debug test and add changelog/docs notes (#11641) (thanks @tyler6204)
2026-02-07 19:46:01 -08:00
Oleg Kossoy
ebe5730401 fix: use STATE_DIR instead of hardcoded ~/.openclaw for identity and canvas (#4824)
* fix: use STATE_DIR instead of hardcoded ~/.openclaw for identity and canvas

device-identity.ts and canvas-host/server.ts used hardcoded
path.join(os.homedir(), '.openclaw', ...) ignoring OPENCLAW_STATE_DIR
env var and the resolveStateDir() logic from config/paths.ts.

This caused ~/.openclaw/identity and ~/.openclaw/canvas directories
to be created even when state dir was overridden or resided elsewhere.

* fix: format and remove duplicate imports

* fix: scope state-dir patch + add regression tests (#4824) (thanks @kossoy)

* fix: align state-dir fallbacks in hooks and agent paths (#4824) (thanks @kossoy)

---------

Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-07 22:16:59 -05:00
大猫子
0499656c59 Docs: fix cron.update param name id → jobId (#11365) (#11467)
* Docs: fix cron.update param name id → jobId (#11365)

* Docs: sync zh-CN cron.update param name id → jobId

* docs: revert manual zh-CN generated docs edit (#11467) (thanks @lailoo)

---------

Co-authored-by: damaozi <1811866786@qq.com>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-07 22:08:41 -05:00
danielcadenhead
05a57e94a4 Fix Nix repository link in README (#7910)
Updated Nix repository link in README.

Co-authored-by: Josh <141778+bolapara@users.noreply.github.com>
Co-authored-by: Seb Slight <19554889+sebslight@users.noreply.github.com>
2026-02-07 21:53:32 -05:00
Gustavo Madeira Santana
c27b03794a chore: updated PR review skills and workflow info on tests + fake timers 2026-02-07 21:47:25 -05:00
Rohan Patil
9866a857a7 docs: clarify onboarding instructions for beginners (#10956) 2026-02-07 21:43:59 -05:00
Gustavo Madeira Santana
e2dea2684f Tests: harden flake hotspots and consolidate provider-auth suites (#11598)
* Tests: harden flake hotspots and consolidate provider-auth suites

* Tests: restore env vars by deleting missing snapshot values

* Tests: use real newline in memory summary filter case

* Tests(memory): use fake timers for qmd timeout coverage

* Changelog: add tests hardening entry for #11598
2026-02-07 21:32:23 -05:00
Tyler Yust
a30c4f45c3 Update CHANGELOG.md for version 2026.2.6-4: Added RPC methods for agent management, fixed context overflow recovery, improved LAN IP handling, enhanced memory retrieval, and updated media understanding for audio transcription. 2026-02-07 18:15:25 -08:00
Vignesh Natarajan
95263f4e60 Memory: add SQLITE_BUSY fallback regression test 2026-02-07 17:55:34 -08:00
Vignesh Natarajan
6f1ba986b3 Memory: make QMD cache eviction callback idempotent 2026-02-07 17:55:34 -08:00
Vignesh Natarajan
c741d008dd Memory: chain forced QMD queue and fail over on busy index 2026-02-07 17:55:34 -08:00
Vignesh Natarajan
0d60ef6fef Memory: queue forced QMD sync and handle sqlite busy reads 2026-02-07 17:55:34 -08:00
Vignesh Natarajan
ce715c4c56 Memory: harden QMD startup, timeouts, and fallback recovery 2026-02-07 17:55:34 -08:00
Tyler Yust
0deb8b0da1 fix: recover from context overflow caused by oversized tool results (#11579)
* fix: gracefully handle oversized tool results causing context overflow

When a subagent reads a very large file or gets a huge tool result (e.g.,
gh pr diff on a massive PR), it can exceed the model's context window in
a single prompt. Auto-compaction can't help because there's no older
history to compact — just one giant tool result.

This adds two layers of defense:

1. Pre-emptive: Hard cap on tool result size (400K chars ≈ 100K tokens)
   applied in the session tool result guard before persistence. This
   prevents extremely large tool results from being stored in full,
   regardless of model context window size.

2. Recovery: When context overflow is detected and compaction fails,
   scan session messages for oversized tool results relative to the
   model's actual context window (30% max share). If found, truncate
   them in the session via branching (creating a new branch with
   truncated content) and retry the prompt.

The truncation preserves the beginning of the content (most useful for
understanding what was read) and appends a notice explaining the
truncation and suggesting offset/limit parameters for targeted reads.

Includes comprehensive tests for:
- Text truncation with newline-boundary awareness
- Context-window-proportional size calculation
- In-memory message truncation
- Oversized detection heuristics
- Guard-level size capping during persistence

* fix: prep fixes for tool result truncation PR (#11579) (thanks @tyler6204)
2026-02-07 17:40:51 -08:00
Aviral
b8c8130efe fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11448)
* fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11329)

When gateway.bind=lan, the HTTP server correctly binds to 0.0.0.0
(all interfaces), but WebSocket connection URLs, probe targets, and
Control UI links were hardcoded to 127.0.0.1. This caused CLI commands
and status probes to show localhost-only URLs even in LAN mode, and
made onboarding display misleading connection info.

- Add pickPrimaryLanIPv4() to gateway/net.ts to detect the machine's
  primary LAN IPv4 address (prefers en0/eth0, falls back to any
  external interface)
- Update pickProbeHostForBind() to use LAN IP when bind=lan
- Update buildGatewayConnectionDetails() to use LAN IP and report
  "local lan <ip>" as the URL source
- Update resolveControlUiLinks() to return LAN-accessible URLs
- Update probe note in status.gather.ts to reflect new behavior
- Add tests for pickPrimaryLanIPv4 and bind=lan URL resolution

Closes #11329

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: move vi.restoreAllMocks to afterEach in pickPrimaryLanIPv4

Per review feedback: avoid calling vi.restoreAllMocks() inside
individual tests as it restores all spies globally and can cause
ordering issues. Use afterEach in the describe block instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Changelog: note LAN bind URLs fix (#11448) (thanks @AnonO6)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-07 19:16:51 -06:00
Tyler Yust
ea423bbbfd feat(sanitize): enhance context overflow error handling in user-facing text
- Added tests to ensure proper sanitization of context overflow errors.
- Introduced a new function to determine when to rewrite context overflow messages.
- Updated the sanitization logic to improve user experience by providing clearer error messages while preserving conversational context.
2026-02-07 17:07:12 -08:00
Advait Paliwal
980f788731 feat(gateway): add agents.create/update/delete methods (#11045)
* feat(gateway): add agents.create/update/delete methods

* fix(lint): preserve memory-lancedb load error cause

* feat(gateway): trash agent files on agents.delete

* chore(protocol): regenerate Swift gateway models

* fix(gateway): stabilize agents.create dirs and agentDir

* feat(gateway): support avatar in agents.create

* fix: prep agents.create/update/delete handlers (#11045) (thanks @advaitpaliwal)

- Reuse movePathToTrash from browser/trash.ts (has ~/.Trash fallback on non-macOS)
- Fix partial-failure: workspace setup now runs before config write
- Always write Name to IDENTITY.md regardless of emoji/avatar
- Add unit tests for agents.create, agents.update, agents.delete
- Add CHANGELOG entry

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-02-07 16:47:58 -08:00
Tak Hoffman
9271fcb3d4 Gateway: fix multi-agent sessions.usage discovery (#11523)
* Gateway: fix multi-agent sessions.usage discovery

* Gateway: resolve sessions.usage keys via sessionId
2026-02-07 17:40:56 -06:00
succ985
b8f740fb14 fix: add .caf to AUDIO_FILE_EXTENSIONS (#10982)
* fix: add .caf to AUDIO_FILE_EXTENSIONS for iMessage voice messages

* fix: add caf audio extension regression coverage (#10982) (thanks @succ985)

---------

Co-authored-by: succ985 <succ985@users.noreply.github.com>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-07 18:04:22 -05:00
max
8da20027c4 CI: skip heavy jobs on docs-only changes (#11328) 2026-02-08 07:43:47 +09:00
Abdullah
9201e140cb Fix typo in FAQ regarding model configuration command (#6048) 2026-02-07 15:48:54 -05:00
Gustavo Madeira Santana
ff80646085 chore: bump pi to 0.52.8 2026-02-07 15:41:27 -05:00
Seb Slight
929a3725d3 docs: canonicalize docs paths and align zh navigation (#11428)
* docs(navigation): canonicalize paths and align zh nav

* chore(docs): remove stray .DS_Store

* docs(scripts): add non-mint docs link audit

* docs(nav): fix zh source paths and preserve legacy redirects (#11428) (thanks @sebslight)

* chore(docs): satisfy lint for docs link audit script (#11428) (thanks @sebslight)
2026-02-07 15:40:35 -05:00
Gustavo Madeira Santana
cde29fef71 added more explicit instructions 2026-02-07 15:27:24 -05:00
Gustavo Madeira Santana
6d1daf2ba5 adding PR review workflow 2026-02-07 15:22:08 -05:00
Tak Hoffman
82419eaad6 Web UI: show Compaction divider in chat history (#11341) 2026-02-07 12:37:22 -06:00
Tak Hoffman
f0722498a4 Agents: include runtime shell (#1835)
* Agents: include runtime shell

* Agents: fix compact runtime build

* chore: fix CLAUDE.md formatting, security regex for secret

---------

Co-authored-by: Tak hoffman <takayukihoffman@gmail.com>
Co-authored-by: quotentiroler <max.nussbaumer@maxhealth.tech>
2026-02-07 09:32:31 -08:00
大猫子
a4d5c7f673 docs: add missing HEARTBEAT.md and MEMORY.md to bootstrap files list (#8105)
* docs: add missing HEARTBEAT.md and MEMORY.md to bootstrap files list

Fixes #7928

The documentation for skipBootstrap and workspace setup was missing
HEARTBEAT.md and MEMORY.md from the bootstrap files list.

Changes:
- docs/gateway/configuration.md: Add HEARTBEAT.md and MEMORY.md
- docs/zh-CN/gateway/configuration.md: Same for Chinese version
- docs/start/openclaw.md: Add HEARTBEAT.md, clarify MEMORY.md is optional
- docs/zh-CN/start/openclaw.md: Same for Chinese version

* fix: reference PR number instead of issue in CHANGELOG

* docs(workspace): align bootstrap file docs with runtime (#8105)

---------

Co-authored-by: damaozi <1811866786@qq.com>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-07 10:51:44 -05:00
Seb Slight
9a3f62cb86 docs: add symptom-first troubleshooting hub and deep runbooks (#11196)
* docs(troubleshooting): add symptom-first troubleshooting runbooks

* docs(troubleshooting): fix approvals command examples

* docs(troubleshooting): wrap symptom cases in accordions

* docs(automation): clarify userTimezone missing-key behavior

* docs(troubleshooting): fix first-60-seconds ladder order
2026-02-07 10:28:19 -05:00
Tyler Yust
1007d71f0c fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)

* fix(security): add timeout to webhook body reading (#6762)

Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.

* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)

* fix: prevent plugins from auto-enabling without user consent (#3961)

Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.

* fix: apply hierarchical mediaMaxMb config to all channels (#8749)

Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.

* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)

Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.

* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)

Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.

* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)

Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.

* fix: send stop-typing signal when run ends with NO_REPLY (#8785)

Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.

* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)

Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
   Multiple agents sharing the same workspace would produce duplicate commands
   with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
   Commands from deleted skills now get cleaned up on restart.

* fix: add size limits to unbounded in-memory caches (#4948)

Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account

* fix: address review concerns (#11093)

- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
Peter Steinberger
9f703a44dc chore(release): 2026.2.6-3 2026-02-07 00:44:32 -08:00
Peter Steinberger
85ed6c7fa4 chore(onboard): reorder xAI + Qianfan providers 2026-02-07 00:43:13 -08:00
Peter Steinberger
ad4dd0422e chore(release): 2026.2.6-2 2026-02-07 00:30:43 -08:00
Peter Steinberger
4ba9809f18 test(hooks): stabilize session-memory hook tests 2026-02-07 00:22:34 -08:00
Peter Steinberger
80d42eb0ba fix(docker): support .mjs entrypoints in images and e2e 2026-02-07 00:22:34 -08:00
Peter Steinberger
2b6cf03b47 fix(build): support daemon-cli .mjs bundles in compat shim 2026-02-07 00:22:34 -08:00
Peter Steinberger
88ffad1c4f Merge PR #8868: add Baidu Qianfan support (thanks @ide-rea) 2026-02-07 00:19:04 -08:00
Peter Steinberger
875324e7c7 docs(changelog): note CI pipeline optimization (#10784) (thanks @mcaxtr) 2026-02-06 23:31:48 -08:00
Marcus Castro
2d7428a7f2 ci: re-enable parallel vitest on Windows CI 2026-02-06 23:31:48 -08:00
Marcus Castro
47596257ea ci: add concurrency controls, consolidate macOS jobs, optimize Windows CI 2026-02-06 23:31:48 -08:00
Peter Steinberger
ab3045cb48 chore(onboard): move xAI below Google 2026-02-06 23:06:55 -08:00
Peter Steinberger
aaddbdae52 chore(release): 2026.2.6-1 2026-02-06 22:48:19 -08:00
Peter Steinberger
8d0e7997c8 chore(onboard): move xAI up in auth list 2026-02-06 22:41:19 -08:00
Peter Steinberger
31a7e4f937 chore(skills): remove bird skill 2026-02-06 22:28:44 -08:00
Peter Steinberger
c5194d8148 fix(dashboard): restore tokenized control ui links 2026-02-06 22:17:09 -08:00
ide-rea
43c0a7fe1c Merge branch 'openclaw:main' into qianfan 2026-02-07 14:07:52 +08:00
Jake
e78ae48e69 fix(memory): add input_type to Voyage AI embeddings for improved retrieval (#10818)
* fix(memory): add input_type to Voyage AI embeddings for improved retrieval

Voyage AI recommends passing input_type='document' when indexing and
input_type='query' when searching. This improves retrieval quality by
optimising the embedding space for each direction.

Changes:
- embedQuery now passes input_type: 'query'
- embedBatch now passes input_type: 'document'
- Batch API request_params includes input_type: 'document'
- Tests updated to verify input_type is passed correctly

* Changelog: note Voyage embeddings input_type fix (#10818) (thanks @mcinteerj)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-06 21:55:09 -06:00
Markus Buhatem Koch
4c1da23a71 Revert previous change from 'Clawdbot' to 'OpenClaw' in lore (#9119) 2026-02-06 21:53:02 -05:00
Val Alexander
3d2fe9284e Fix repository links in formal-verification.md (#10200)
Updated repository links for formal verification models.
2026-02-06 21:47:55 -05:00
Peter Steinberger
a3b5f1b15c fix(build): unblock pnpm build dts 2026-02-06 18:43:11 -08:00
DEOKLYONG MOON
d1dc60774b Docs: fix broken /plugins links (#9308)
* Docs: fix broken /plugins links to /plugin

The documentation linked to /plugins which doesn't exist.
The correct path is /plugin (singular) which contains the
plugins overview documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: drop manual zh-CN doc edits from plugins link fix

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-06 21:08:26 -05:00
Tyler Yust
d90cac990c fix: cron scheduler reliability, store hardening, and UX improvements (#10776)
* refactor: update cron job wake mode and run mode handling

- Changed default wake mode from 'next-heartbeat' to 'now' in CronJobEditor and related CLI commands.
- Updated cron-tool tests to reflect changes in run mode, introducing 'due' and 'force' options.
- Enhanced cron-tool logic to handle new run modes and ensure compatibility with existing job structures.
- Added new tests for delivery plan consistency and job execution behavior under various conditions.
- Improved normalization functions to handle wake mode and session target casing.

This refactor aims to streamline cron job configurations and enhance the overall user experience with clearer defaults and improved functionality.

* test: enhance cron job functionality and UI

- Added tests to ensure the isolated agent correctly announces the final payload text when delivering messages via Telegram.
- Implemented a new function to pick the last deliverable payload from a list of delivery payloads.
- Enhanced the cron service to maintain legacy "every" jobs while minute cron jobs recompute schedules.
- Updated the cron store migration tests to verify the addition of anchorMs to legacy every schedules.
- Improved the UI for displaying cron job details, including job state and delivery information, with new styles and layout adjustments.

These changes aim to improve the reliability and user experience of the cron job system.

* test: enhance sessions thinking level handling

- Added tests to verify that the correct thinking levels are applied during session spawning.
- Updated the sessions-spawn-tool to include a new parameter for overriding thinking levels.
- Enhanced the UI to support additional thinking levels, including "xhigh" and "full", and improved the handling of current options in dropdowns.

These changes aim to improve the flexibility and accuracy of thinking level configurations in session management.

* feat: enhance session management and cron job functionality

- Introduced passthrough arguments in the test-parallel script to allow for flexible command-line options.
- Updated session handling to hide cron run alias session keys from the sessions list, improving clarity.
- Enhanced the cron service to accurately record job start times and durations, ensuring better tracking of job execution.
- Added tests to verify the correct behavior of the cron service under various conditions, including zero-delay timers.

These changes aim to improve the usability and reliability of session and cron job management.

* feat: implement job running state checks in cron service

- Added functionality to prevent manual job runs if a job is already in progress, enhancing job management.
- Updated the `isJobDue` function to include checks for running jobs, ensuring accurate scheduling.
- Enhanced the `run` function to return a specific reason when a job is already running.
- Introduced a new test case to verify the behavior of forced manual runs during active job execution.

These changes aim to improve the reliability and clarity of cron job execution and management.

* feat: add session ID and key to CronRunLogEntry model

- Introduced `sessionid` and `sessionkey` properties to the `CronRunLogEntry` struct for enhanced tracking of session-related information.
- Updated the initializer and Codable conformance to accommodate the new properties, ensuring proper serialization and deserialization.

These changes aim to improve the granularity of logging and session management within the cron job system.

* fix: improve session display name resolution

- Updated the `resolveSessionDisplayName` function to ensure that both label and displayName are trimmed and default to an empty string if not present.
- Enhanced the logic to prevent returning the key if it matches the label or displayName, improving clarity in session naming.

These changes aim to enhance the accuracy and usability of session display names in the UI.

* perf: skip cron store persist when idle timer tick produces no changes

recomputeNextRuns now returns a boolean indicating whether any job
state was mutated. The idle path in onTimer only persists when the
return value is true, eliminating unnecessary file writes every 60s
for far-future or idle schedules.

* fix: prep for merge - explicit delivery mode migration, docs + changelog (#10776) (thanks @tyler6204)
2026-02-06 18:03:03 -08:00
Peter Steinberger
0dd7033521 chore(lockfile): fix pnpm-lock 2026-02-06 17:56:22 -08:00
Raymond Berger
c80a09fc2f Fix QMD CLI installation link in memory.md (#8647)
Correct the installation link for the QMD CLI in the documentation.
2026-02-06 20:53:47 -05:00
Peter Steinberger
f831c48e56 docs(changelog): highlight Opus 4.6 + Codex 5.3 first 2026-02-06 17:28:46 -08:00
Peter Steinberger
c237a82b42 docs(changelog): curate 2026.2.6 2026-02-06 16:56:29 -08:00
Peter Steinberger
94b2fc14f2 chore(deps): bump carbon beta 2026-02-06 16:56:25 -08:00
Peter Steinberger
0daf416908 fix(agents): add Opus 4.6 forward-compat fallback 2026-02-06 16:56:21 -08:00
Peter Steinberger
dca8cf958b chore(deps): update deps 2026-02-06 16:37:56 -08:00
Seb Slight
93bf75279f docs(imessage): improve macOS TCC troubleshooting guidance (#10781) 2026-02-06 19:21:52 -05:00
gitpds
fe308a3aa1 docs(imessage): add macOS TCC troubleshooting 2026-02-06 19:10:01 -05:00
Peter Steinberger
7be921c434 docs(changelog): refresh 2026.2.6 since v2026.2.3 2026-02-06 15:47:10 -08:00
Peter Steinberger
5163833be5 docs: fix markdownlint fragments + headings 2026-02-06 15:45:39 -08:00
Peter Steinberger
d898ad6807 fix(telegram): cast fetch for grammY ApiClientOptions 2026-02-06 15:45:34 -08:00
Peter Steinberger
677450cd9b chore(release): bump version to 2026.2.6 2026-02-06 15:37:31 -08:00
Peter Steinberger
ac5944cde7 docs(changelog): include merged PRs since v2026.2.3 2026-02-06 15:36:13 -08:00
Peter Steinberger
3b768a2851 docs(changelog): prepare 2026.2.6 2026-02-06 15:30:17 -08:00
Shadril Hassan Shifat
2c8af78d20 fix(hooks): replace debug console.log with proper subsystem logging in session-memory (#10730)
* fix: replace debug console.log with proper subsystem logging in session-memory

* fix(hooks): normalize session-memory subsystem logging

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-06 17:22:38 -06:00
calvin-hpnet
48b0fd8d88 feat(antigravity): update default model to Claude Opus 4.6 (#10720)
* feat(antigravity): update default model to Claude Opus 4.6

Claude Opus 4.5 has been replaced by Claude Opus 4.6 on the
Antigravity (Google Cloud Code Assist) platform.

- Update DEFAULT_MODEL in google-antigravity-auth extension
- Update testing docs to reference the new model

* fix: update remaining antigravity opus 4.5 refs in zh-CN docs and tests

Address review comments from Greptile:
- Update zh-CN/testing.md antigravity model references
- Update pi-tools-agent-config.test.ts model IDs

* Antigravity: default OAuth model to Opus 4.6 (#10720) (thanks @calvin-hpnet)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-06 16:42:57 -06:00
Tak Hoffman
40425db0f1 feat(memory): document Voyage embeddings + VOYAGE_API_KEY (#7078) (thanks @mcinteerj) (#10699) 2026-02-06 15:51:47 -06:00
Jake
6965a2cc9d feat(memory): native Voyage AI support (#7078)
* feat(memory): add native Voyage AI embedding support with batching

Cherry-picked from PR #2519, resolved conflict in memory-search.ts
(hasRemote -> hasRemoteConfig rename + added voyage provider)

* fix(memory): optimize voyage batch memory usage with streaming and deduplicate code

Cherry-picked from PR #2519. Fixed lint error: changed this.runWithConcurrency
to use imported runWithConcurrency function after extraction to internal.ts
2026-02-06 15:09:32 -06:00
Tak Hoffman
e3d3893d5d Docs: revise PR and issue submission guides (#10617)
* Docs: revise PR submission guide

* Docs: revise issue submission guide
2026-02-06 13:29:11 -06:00
Yida-Dev
4216449405 fix: guard resolveUserPath against undefined input (#10176)
* fix: guard resolveUserPath against undefined input

When subagent spawner omits workspaceDir, resolveUserPath receives
undefined and crashes on .trim().  Add a falsy guard that falls back
to process.cwd(), matching the behavior callers already expect.

Closes #10089

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden runner workspace fallback (#10176) (thanks @Yida-Dev)

* fix: harden workspace fallback scoping (#10176) (thanks @Yida-Dev)

* refactor: centralize workspace fallback classification and redaction (#10176) (thanks @Yida-Dev)

* test: remove explicit any from utils mock (#10176) (thanks @Yida-Dev)

* security: reject malformed agent session keys for workspace resolution (#10176) (thanks @Yida-Dev)

---------

Co-authored-by: Yida-Dev <reyifeijun@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-06 13:16:58 -05:00
Tak Hoffman
5842bcaaf7 Docs: add PR sign-off template (#10561) 2026-02-06 11:58:39 -06:00
Seb Slight
991cf4d7fe Docs: revamp installer internals for readability and accuracy (#10499)
* docs(install): revamp installer internals for readability and accuracy

Restructure the installer internals page with better flow and Mintlify
components (CardGroup, Steps, Tabs, AccordionGroup). All flags, env vars,
and behavioral descriptions cross-checked against install.sh,
install-cli.sh, and install.ps1 source code.

- Add CardGroup chooser and Quick Commands section at top
- Organize each script into consistent Flow → Examples → Reference pattern
- Move flags/env var tables into collapsible Accordions
- Consolidate troubleshooting into AccordionGroup at bottom
- Add missing flags (--version, --beta, --verbose, --help, etc.)
- Add missing env vars (OPENCLAW_VERSION, OPENCLAW_BETA, etc.)
- Document install-cli.sh fully (was one paragraph)
- Fix non-interactive checkout detection behavior (defaults to npm)
- Use --proto/--tlsv1.2 in curl examples to match script usage
- No content deleted; all original info preserved or relocated

* fix(docs): correct in-page anchor hrefs for installer cards

* docs(install): replace CardGroup with table for installer overview
2026-02-06 10:49:38 -05:00
Seb Slight
578a6e27aa Docs: enable markdownlint autofixables except list numbering (#10476)
* docs(markdownlint): enable autofixable rules except list numbering

* docs(zalo): fix malformed bot platform link
2026-02-06 10:08:59 -05:00
Sebastian
0a1f4f666a revert(docs): undo markdownlint autofix churn 2026-02-06 10:00:08 -05:00
Sebastian
c7aec0660e docs(markdownlint): enable autofixable rules and normalize links 2026-02-06 09:55:12 -05:00
Sebastian
1bf9f237f7 docs: linting 2026-02-06 09:35:57 -05:00
Sebastian
134c03a903 feat: add markdownlint configuration for documentation formatting and linting 2026-02-06 09:14:36 -05:00
Seb Slight
18b480dd3e Docs: sharpen Install tab to stop duplicating Getting Started (#10416)
* docs(install): reframe install overview to stop duplicating getting started

* docs(install): link default installer row to getting started, not internals

* docs(install): use Mintlify components for install overview

* docs(install): fix card grid layout with CardGroup

* docs(install): platform tabs for global install, npm/pnpm as accordion

* docs(install): add PowerShell no-onboard alternative

* docs(install): add repo link to from-source clone step

* docs(install): capitalize OpenClaw in repo link

* docs(install): add pnpm link --global to from-source steps

* docs(install): rewrite install overview for clarity and flow

* docs(install): use tooltip for Windows WSL2 recommendation

* docs(install): use Note box for Windows WSL2 recommendation

* docs(install): group install methods under single heading

* docs(install): standardize tab labels across installer sections

* docs(install): rewrite Node.js page with install instructions and better structure

* docs(install): clarify Node.js page intro

* docs(install): scope auto-install note to installer script, link Node page

* docs(install): fix installer script link to internals page

* docs: rename Install methods nav group to Other install methods

* docs(install): link to on-page anchor, use Tip box for recommended

* docs(install): wrap install methods in AccordionGroup with Tip box

* docs: move Node.js page from Install to Help > Environment and debugging

* docs(install): add complete flags and env vars reference to installer internals

* docs(install): use stable troubleshooting anchor for Node.js link

* docs(install): fix Node page installer anchor

* docs(install): fix broken installer script anchor in requirements note
2026-02-06 08:55:05 -05:00
ideoutrea
0b51f0d762 Fix conflicts 2026-02-06 18:16:20 +08:00
ideoutrea
7a9deb2400 Resolve conflicts 2026-02-06 18:13:42 +08:00
ide-rea
3997316fb0 Merge branch 'main' into qianfan 2026-02-06 17:58:28 +08:00
ideoutrea
360851366f Support ERNIE-5.0-Thinking-Preview 2026-02-06 17:49:54 +08:00
Gustavo Madeira Santana
c75275f109 Update: harden control UI asset handling in update flow (#10146)
* Update: harden control UI asset handling in update flow

* fix: harden update doctor entrypoint guard (#10146) (thanks @gumadeiras)
2026-02-06 01:14:00 -05:00
Tak Hoffman
50e687d17d Docs: add PR and issue submission guides (#10150)
* Docs: add PR and issue submission guides

* Docs: fix LLM-assisted wording
2026-02-05 23:59:47 -06:00
Gustavo Madeira Santana
4a59b7786b fix: CLI harden update restart imports and fix nested bundle version resolution 2026-02-06 00:09:48 -05:00
Tak Hoffman
8a352c8f9d Web UI: add token usage dashboard (#10072)
* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature

- Restore normalizeGatewayUrl() to validate ws:/wss: protocol
- Restore isTopLevelWindow() guard for iframe security
- Revert syncUrlWithSessionKey signature (host param was unused)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj)

* Usage: enrich metrics dashboard

* Usage: add latency + model trends

* Gateway: improve usage log parsing

* UI: add usage query helpers

* UI: client-side usage filter + debounce

* Build: harden write-cli-compat timing

* UI: add conversation log filters

* UI: fix usage dashboard lint + state

* Web UI: default usage dates to local day

* Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman)

---------

Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
2026-02-05 22:35:46 -06:00
Gustavo Madeira Santana
b40da2cb7a fix: remove dead restore control-ui step from update runner 2026-02-05 22:10:55 -05:00
Gustavo Madeira Santana
72245855e5 fix: add fallback for Control UI asset resolution in global installs 2026-02-05 22:03:43 -05:00
Gustavo Madeira Santana
7b2a221212 chore: run lint step after build during preflight check 2026-02-05 21:22:27 -05:00
Sebastian
ac0c2f260f docs: update clawtributors (add @unisone) 2026-02-05 21:19:42 -05:00
Alex Zaytsev
d2aee7da68 docs: add activeHours to heartbeat field notes and examples (#9366)
Co-authored-by: unisone <unisone@users.noreply.github.com>
2026-02-05 21:18:57 -05:00
Coy Geek
717129f7f9 fix: silence unused hook token url param (#9436)
* fix: Gateway authentication token exposed in URL query parameters

* fix: silence unused hook token url param

* fix: remove gateway auth tokens from URLs (#9436) (thanks @coygeek)

* test: fix Windows path separators in audit test (#9436)

---------

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-02-05 18:08:29 -08:00
Matt Ezell
b1430aaaca Chore: Update memory.md with current default workspace path (#9559)
Removed 'clawd' workspace reference - updated with current default workspace path of '~/.openclaw/workspace'
2026-02-05 21:06:14 -05:00
Shailesh
bccdc95a9b Cap sessions_history payloads to prevent context overflow (#10000)
* Cap sessions_history payloads to prevent context overflow

* fix: harden sessions_history payload caps

* fix: cap sessions_history payloads to prevent context overflow (#10000) (thanks @gut-puncture)

---------

Co-authored-by: Shailesh Rana <shaileshrana@ShaileshMM.local>
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-02-05 17:50:57 -08:00
cpojer
328b69be17 chore: Fix audit test on Windows. 2026-02-06 10:22:48 +09:00
cpojer
f16e32b73d fix: Do not process.exit(0) in the middle of a test. 2026-02-06 09:57:51 +09:00
cpojer
8abce8a84d fix: onToolResult fallback is not expected. 2026-02-06 09:55:56 +09:00
therealZpoint-bot
c448e5da6f fix(docs): correct OpenCode Zen description in code comment (#9998)
* fix(docs): correct OpenCode Zen description in code comment

OpenCode Zen is a pay-as-you-go token-based API, not a $200/month
subscription. The subscription tiers ($20/$100/$200) are OpenCode Black,
a separate product.

This fixes the misleading comment that conflated Zen with Black.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: align OpenCode Zen billing copy (#9998) (thanks @therealZpoint-bot)

---------

Co-authored-by: Claude <claude@archibald.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 19:55:02 -05:00
ideoutrea
7af00f040a Optimize import 2026-02-05 14:53:38 +08:00
ideoutrea
4d30f97407 Fix key resolve 2026-02-05 14:40:56 +08:00
ideoutrea
ff948a6dd7 Optimize doc 2026-02-05 14:04:23 +08:00
ideoutrea
ad759c9446 Optimize format 2026-02-05 13:50:09 +08:00
ide-rea
9ccbd57016 Merge branch 'openclaw:main' into qianfan 2026-02-05 13:36:44 +08:00
ideoutrea
52c9d3480f Add auth choice 2026-02-05 13:35:35 +08:00
ide-rea
517a8eafe5 Merge branch 'openclaw:main' into qianfan 2026-02-05 12:43:21 +08:00
ideoutrea
c8e67ad5d5 Fix import error 2026-02-05 12:39:38 +08:00
ideoutrea
fb5280e1b5 optimize doc 2026-02-04 22:57:34 +08:00
ide-rea
009abd306a Merge branch 'main' into qianfan 2026-02-04 22:39:13 +08:00
ideoutrea
8c53dfb74f Optimize doc 2026-02-04 22:30:42 +08:00
ideoutrea
7bf4080608 Fix format 2026-02-04 22:27:51 +08:00
ideoutrea
1de05ad068 Add baidu qianfan model provider 2026-02-04 22:27:49 +08:00
ideoutrea
30ac80b96b Add baidu qianfan model provider 2026-02-04 16:36:37 +08:00
782 changed files with 36454 additions and 6435 deletions

View File

@@ -0,0 +1,126 @@
# PR Review Instructions
Please read this in full and do not skip sections.
## Working rule
Skills execute workflow, maintainers provide judgment.
Always pause between skills to evaluate technical direction, not just command success.
These three skills must be used in order:
1. `review-pr`
2. `prepare-pr`
3. `merge-pr`
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
Treat PRs as reports first, code second.
If submitted code is low quality, ignore it and implement the best solution for the problem.
Do not continue if you cannot verify the problem is real or test the fix.
## PR quality bar
- Do not trust PR code by default.
- Do not merge changes you cannot validate with a reproducible problem and a tested fix.
- Keep types strict. Do not use `any` in implementation code.
- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output.
- Keep implementations properly scoped. Fix root causes, not local symptoms.
- Identify and reuse canonical sources of truth so behavior does not drift across the codebase.
- Harden changes. Always evaluate security impact and abuse paths.
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
## Unified workflow
Entry criteria:
- PR URL/number is known.
- Problem statement is clear enough to attempt reproduction.
- A realistic verification path exists (tests, integration checks, or explicit manual validation).
### 1) `review-pr`
Purpose:
- Review only: correctness, value, security risk, tests, docs, and changelog impact.
- Produce structured findings and a recommendation.
Expected output:
- Recommendation: ready, needs work, needs discussion, or close.
- `.local/review.md` with actionable findings.
Maintainer checkpoint before `prepare-pr`:
```
What problem are they trying to solve?
What is the most optimal implementation?
Is the code properly scoped?
Can we fix up everything?
Do we have any questions?
```
Stop and escalate instead of continuing if:
- The problem cannot be reproduced or confirmed.
- The proposed PR scope does not match the stated problem.
- The design introduces unresolved security or trust-boundary concerns.
### 2) `prepare-pr`
Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main`, fix blocker/important findings, and run gates.
Expected output:
- Updated code and tests on the PR head branch.
- `.local/prep.md` with changes, verification, and current HEAD SHA.
- Final status: `PR is ready for /mergepr`.
Maintainer checkpoint before `merge-pr`:
```
Is this the most optimal implementation?
Is the code properly scoped?
Is the code properly typed?
Is the code hardened?
Do we have enough tests?
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do not add performative tests, ensure tests are real and there are no regressions.
Take your time, fix it properly, refactor if necessary.
Do you see any follow-up refactors we should do?
Did any changes introduce any potential security vulnerabilities?
```
Stop and escalate instead of continuing if:
- You cannot verify behavior changes with meaningful tests or validation.
- Fixing findings requires broad architecture changes outside safe PR scope.
- Security hardening requirements remain unresolved.
### 3) `merge-pr`
Purpose:
- Merge only after review and prep artifacts are present and checks are green.
- Use squash merge flow and verify the PR ends in `MERGED` state.
Go or no-go checklist before merge:
- All BLOCKER and IMPORTANT findings are resolved.
- Verification is meaningful and regression risk is acceptably low.
- Docs and changelog are updated when required.
- Required CI checks are green and the branch is not behind `main`.
Expected output:
- Successful merge commit and recorded merge SHA.
- Worktree cleanup after successful merge.
Maintainer checkpoint after merge:
- Were any refactors intentionally deferred and now need follow-up issue(s)?
- Did this reveal broader architecture or test gaps we should address?

View File

@@ -28,7 +28,7 @@ Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after s
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/Development/openclaw`, not `~/openclaw`.
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
- Expect cleanup to remove `.local/` artifacts.
@@ -49,7 +49,7 @@ Create a checklist of all merge steps, print it, then continue and execute the c
Use an isolated worktree for all merge work.
```sh
cd ~/Development/openclaw
cd ~/dev/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
@@ -167,7 +167,7 @@ gh pr view <PR> --json state --jq .state
Run cleanup only if step 6 returned `MERGED`.
```sh
cd ~/Development/openclaw
cd ~/dev/openclaw
git worktree remove ".worktrees/pr-<PR>" --force

View File

@@ -30,7 +30,7 @@ Prepare a PR branch for merge with review fixes, green gates, and an updated hea
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Do not run `git clean -fdx`.
- Do not run `git add -A` or `git add .`.

View File

@@ -28,7 +28,7 @@ Perform a thorough review-only PR assessment and return a structured recommendat
## Known Failure Modes
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Do not stop after printing the checklist. That is not completion.
## Writing Style for Output
@@ -51,7 +51,7 @@ Create a checklist of all review steps, print it, then continue and execute the
Use an isolated worktree for all review work.
```sh
cd ~/Development/openclaw
cd ~/dev/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel

View File

@@ -0,0 +1,41 @@
name: Detect docs-only changes
description: >
Outputs docs_only=true when all changed files are under docs/ or are
markdown (.md/.mdx). Fail-safe: if detection fails, outputs false (run
everything). Uses git diff — no API calls, no extra permissions needed.
outputs:
docs_only:
description: "'true' if all changes are docs/markdown, 'false' otherwise"
value: ${{ steps.check.outputs.docs_only }}
runs:
using: composite
steps:
- name: Detect docs-only changes
id: check
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
# Use the exact base SHA from the event payload — stable regardless
# of base branch movement (avoids origin/<ref> drift).
BASE="${{ github.event.pull_request.base.sha }}"
fi
# Fail-safe: if we can't diff, assume non-docs (run everything)
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Check if all changed files are docs or markdown
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
if [ -z "$NON_DOCS" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping heavy jobs"
else
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi

View File

@@ -0,0 +1,41 @@
name: Setup pnpm + store cache
description: Prepare pnpm via corepack and restore pnpm store cache.
inputs:
pnpm-version:
description: pnpm version to activate via corepack.
required: false
default: "10.23.0"
cache-key-suffix:
description: Suffix appended to the cache key.
required: false
default: "node22"
runs:
using: composite
steps:
- name: Setup pnpm (corepack retry)
shell: bash
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-

View File

@@ -0,0 +1,64 @@
# OpenClaw Codebase Patterns
**Always reuse existing code - no redundancy!**
## Tech Stack
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
- **Language**: TypeScript (ESM, strict mode)
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
- **Tests**: Vitest with V8 coverage
- **CLI Framework**: Commander + clack/prompts
- **Build**: tsdown (outputs to `dist/`)
## Anti-Redundancy Rules
- Avoid files that just re-export from another file. Import directly from the original source.
- If a function already exists, import it - do NOT create a duplicate in another file.
- Before creating any formatter, utility, or helper, search for existing implementations first.
## Source of Truth Locations
### Formatting Utilities (`src/infra/`)
- **Time formatting**: `src\infra\format-time`
**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.**
### Terminal Output (`src/terminal/`)
- Tables: `src/terminal/table.ts` (`renderTable`)
- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.)
- Progress: `src/cli/progress.ts` (spinners, progress bars)
### CLI Patterns
- CLI option wiring: `src/cli/`
- Commands: `src/commands/`
- Dependency injection via `createDefaultDeps`
## Import Conventions
- Use `.js` extension for cross-package imports (ESM)
- Direct imports only - no re-export wrapper files
- Types: `import type { X }` for type-only imports
## Code Quality
- TypeScript (ESM), strict typing, avoid `any`
- Keep files under ~700 LOC - extract helpers when larger
- Colocated tests: `*.test.ts` next to source files
- Run `pnpm check` before commits (lint + format)
- Run `pnpm tsgo` for type checking
## Stack & Commands
- **Package manager**: pnpm (`pnpm install`)
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
- **Type-check**: `pnpm tsgo`
- **Lint/format**: `pnpm check`
- **Tests**: `pnpm test`
- **Build**: `pnpm build`
If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality.

View File

@@ -2,10 +2,126 @@ name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
install-check:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-only
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
# Push to main keeps broad coverage.
changed-scope:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: ubuntu-latest
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
run_android: ${{ steps.scope.outputs.run_android }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
- name: Detect changed scopes
id: scope
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
# Fail-safe: run broad checks if detection fails.
echo "run_node=true" >> "$GITHUB_OUTPUT"
echo "run_macos=true" >> "$GITHUB_OUTPUT"
echo "run_android=true" >> "$GITHUB_OUTPUT"
exit 0
fi
run_node=false
run_macos=false
run_android=false
has_non_docs=false
has_non_native_non_docs=false
while IFS= read -r path; do
[ -z "$path" ] && continue
case "$path" in
docs/*|*.md|*.mdx)
continue
;;
*)
has_non_docs=true
;;
esac
case "$path" in
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
run_macos=true
;;
esac
case "$path" in
apps/android/*|apps/shared/*)
run_android=true
;;
esac
case "$path" in
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
run_node=true
;;
esac
case "$path" in
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
;;
*)
has_non_native_non_docs=true
;;
esac
done <<< "$CHANGED"
# If there are non-doc files outside native app trees, keep Node checks enabled.
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
run_node=true
fi
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -32,20 +148,76 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm (corepack retry)
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies (frozen)
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Build dist
run: pnpm build
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist-build
path: dist/
retention-days: 1
install-check:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare pnpm@10.23.0 --activate; then
pnpm -v
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
echo "Submodule update failed (attempt $attempt/5). Retrying"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Runtime versions
run: |
node -v
@@ -66,6 +238,8 @@ jobs:
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
checks:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
@@ -74,21 +248,15 @@ jobs:
- runtime: node
task: tsgo
command: pnpm tsgo
- runtime: node
task: lint
command: pnpm build && pnpm lint
- runtime: node
task: test
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: node
task: format
command: pnpm format
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -114,19 +282,11 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm (corepack retry)
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare pnpm@10.23.0 --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -156,6 +316,76 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
# Lint and format always run, even on docs-only changes.
checks-lint:
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: pnpm lint
- task: format
command: pnpm format
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Runtime versions
run: |
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
@@ -182,10 +412,14 @@ jobs:
fi
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-windows-2025
env:
NODE_OPTIONS: --max-old-space-size=4096
CLAWDBOT_TEST_WORKERS: 1
# Keep total concurrency predictable on the 4 vCPU runner:
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
OPENCLAW_TEST_WORKERS: 2
defaults:
run:
shell: bash
@@ -194,8 +428,8 @@ jobs:
matrix:
include:
- runtime: node
task: build & lint
command: pnpm build && pnpm lint
task: lint
command: pnpm lint
- runtime: node
task: test
command: pnpm canvas:a2ui:bundle && pnpm test
@@ -208,6 +442,25 @@ jobs:
with:
submodules: false
- name: Try to exclude workspace from Windows Defender (best-effort)
shell: pwsh
run: |
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
if (-not $cmd) {
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
exit 0
}
try {
# Defender sometimes intercepts process spawning (vitest workers). If this fails
# (eg hardened images), keep going and rely on worker limiting above.
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
Write-Host "Defender exclusions applied."
} catch {
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Checkout submodules (retry)
run: |
set -euo pipefail
@@ -221,25 +474,31 @@ jobs:
done
exit 1
- name: Download dist artifact (lint lane)
if: matrix.task == 'lint'
uses: actions/download-artifact@v4
with:
name: dist-build
path: dist/
- name: Verify dist artifact (lint lane)
if: matrix.task == 'lint'
run: |
set -euo pipefail
test -s dist/index.js
test -s dist/plugin-sdk/index.js
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm (corepack retry)
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare pnpm@10.23.0 --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -269,15 +528,14 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks-macos:
if: github.event_name == 'pull_request'
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
needs: [docs-scope, changed-scope]
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -297,25 +555,18 @@ jobs:
done
exit 1
# --- Node/pnpm setup (for TS tests) ---
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm (corepack retry)
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare pnpm@10.23.0 --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Runtime versions
run: |
@@ -336,71 +587,20 @@ jobs:
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- task: build
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- task: test
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
run: pnpm test
# --- Xcode/Swift setup ---
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: |
brew install xcodegen swiftlint swiftformat
run: brew install xcodegen swiftlint swiftformat
- name: Show toolchain
run: |
@@ -408,8 +608,35 @@ jobs:
xcodebuild -version
swift --version
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
- name: Swift lint
run: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Swift build (release)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Swift test
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest
@@ -584,6 +811,8 @@ jobs:
PY
android:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false

View File

@@ -3,6 +3,10 @@ name: Formal models (informational conformance)
on:
pull_request:
concurrency:
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
cancel-in-progress: true
jobs:
formal_conformance:
runs-on: ubuntu-latest

View File

@@ -6,8 +6,28 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
docs-scope:
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-only
install-smoke:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout CLI

View File

@@ -3,6 +3,11 @@ name: Workflow Sanity
on:
pull_request:
push:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
no-tabs:

3
.gitignore vendored
View File

@@ -72,6 +72,7 @@ USER.md
.serena/
# Agent credentials and memory (NEVER COMMIT)
memory/
/memory/
.agent/*.json
!.agent/workflows/
local/

52
.markdownlint-cli2.jsonc Normal file
View File

@@ -0,0 +1,52 @@
{
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**"],
"config": {
"default": true,
"MD013": false,
"MD025": false,
"MD029": false,
"MD033": {
"allowed_elements": [
"Note",
"Info",
"Tip",
"Warning",
"Card",
"CardGroup",
"Columns",
"Steps",
"Step",
"Tabs",
"Tab",
"Accordion",
"AccordionGroup",
"CodeGroup",
"Frame",
"Callout",
"ParamField",
"ResponseField",
"RequestExample",
"ResponseExample",
"img",
"a",
"br",
"details",
"summary",
"p",
"strong",
"picture",
"source",
"Tooltip",
"Check",
],
},
"MD036": false,
"MD040": false,
"MD041": false,
"MD046": false,
},
}

View File

@@ -24,6 +24,7 @@
"assets/",
"dist/",
"docs/_layouts/",
"extensions/",
"node_modules/",
"patches/",
"pnpm-lock.yaml/",

View File

@@ -95,6 +95,8 @@
- Group related changes; avoid bundling unrelated refactors.
- 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.
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
- 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.

View File

@@ -2,27 +2,92 @@
Docs: https://docs.openclaw.ai
## 2026.2.4
## 2026.2.6-4
### Added
- Gateway: add `agents.create`, `agents.update`, `agents.delete` RPC methods for web UI agent management. (#11045) Thanks @advaitpaliwal.
- Gateway: add node command allowlists (default-deny unknown node commands; configurable via `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`). (#11755) Thanks @mbelinky.
- Plugins: add `device-pair` (Telegram `/pair` flow) and `phone-control` (iOS/Android node controls). (#11755) Thanks @mbelinky.
- iOS: add alpha iOS node app (Telegram setup-code pairing + Talk/Chat surfaces). (#11756) Thanks @mbelinky.
- Docs: seed initial ja-JP translations (POC) and make docs-i18n prompts language-pluggable for Japanese. (#11988) Thanks @joshp123.
- Paths: add `OPENCLAW_HOME` environment variable for overriding the home directory used by all internal path resolution. (#12091) Thanks @sebslight.
### Fixes
- Exec approvals: format forwarded command text as inline/fenced monospace for safer approval scanning across channels. (#11937)
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
- Docs: fix language switcher ordering and Japanese locale flag in Mintlify nav. (#12023) Thanks @joshp123.
- Paths: make internal path resolution respect `HOME`/`USERPROFILE` before `os.homedir()` across config, agents, sessions, pairing, cron, and CLI profiles. (#12091) Thanks @sebslight.
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @seans-openclawbot.
- Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757.
- Gateway: stabilize chat routing by canonicalizing node session keys for node-originated chat methods. (#11755) Thanks @mbelinky.
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
- Cron: route text-only isolated agent announces through the shared subagent announce flow; add exponential backoff for repeated errors; preserve future `nextRunAtMs` on restart; include current-boundary schedule matches; prevent stale threadId reuse across targets; and add per-job execution timeout. (#11641) Thanks @tyler6204.
- Subagents: stabilize announce timing, preserve compaction metrics across retries, clamp overflow-prone long timeouts, and cap impossible context usage token totals. (#11551) Thanks @tyler6204.
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
- Telegram: render markdown spoilers with `<tg-spoiler>` HTML tags. (#11543) Thanks @ezhikkk.
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705)
- Memory/QMD: log explicit warnings when `memory.qmd.scope` blocks a search request. (#10191)
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
## 2026.2.6
### Changes
- Hygiene: remove `workspace:*` from `dependencies` in msteams, nostr, zalo extensions (breaks external `npm install`; keep in `devDependencies` only).
- Hygiene: add non-root `sandbox` user to `Dockerfile.sandbox` and `Dockerfile.sandbox-browser`.
- Hygiene: remove dead `vitest` key from `package.json` (superseded by `vitest.config.ts`).
- Hygiene: remove redundant top-level `overrides` from `package.json` (pnpm uses `pnpm.overrides`).
- Hygiene: sync `onlyBuiltDependencies` between `pnpm-workspace.yaml` and `package.json` (add missing `node-llama-cpp`, sort alphabetically).
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
### Added
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
### Fixes
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
- Update: harden Control UI asset handling in update flow. (#10146) Thanks @gumadeiras.
- Security: add skill/plugin code safety scanner; redact credentials from config.get gateway responses. (#9806, #9858) Thanks @abdelsfane.
- Exec approvals: coerce bare string allowlist entries to objects. (#9903) Thanks @mcaxtr.
- Slack: add mention stripPatterns for /new and /reset. (#9971) Thanks @ironbyte-rgb.
- Chrome extension: fix bundled path resolution. (#8914) Thanks @kelvinCB.
- Compaction/errors: allow multiple compaction retries on context overflow; show clear billing errors. (#8928, #8391) Thanks @Glucksberg.
## 2026.2.3
### Changes
- Agents: bump pi-mono packages to 0.52.5. (#9949) Thanks @gumadeiras.
- Models: default Anthropic model to `anthropic/claude-opus-4-6`. (#9853) Thanks @TinyTb.
- Models/Onboarding: refresh provider defaults, update OpenAI/OpenAI Codex wizard defaults, and harden model allowlist initialization for first-time configs with matching docs/tests. (#9911) Thanks @gumadeiras.
- Telegram: auto-inject forum topic `threadId` in message tool and subagent announce so media, buttons, and subagent results land in the correct topic instead of General. (#7235) Thanks @Lukavyi.
- Security: add skill/plugin code safety scanner that detects dangerous patterns (command injection, eval, data exfiltration, obfuscated code, crypto mining, env harvesting) in installed extensions. Integrated into `openclaw security audit --deep` and plugin install flow; scan failures surface as warnings. (#9806) Thanks @abdelsfane.
- CLI: sort `openclaw --help` commands (and options) alphabetically. (#8068) Thanks @deepsoumya617.
- Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206)
- Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit<BuildTelegramMessageContextParams>`, widen `allMedia` to `TelegramMediaRef[]`. (#9180)
- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077)
- Telegram: allow per-group and per-topic `groupPolicy` overrides under `channels.telegram.groups`. (#9775) Thanks @nicolasstanley.
- Feishu: expand channel handling (posts with images, doc links, routing, reactions/typing, replies, native commands). (#8975) Thanks @jiulingyun.
- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
- Onboarding: add xAI (Grok) auth choice and provider defaults. (#9885) Thanks @grp06.
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
- Docs: strengthen secure DM mode guidance for multi-user inboxes with an explicit warning and example. (#9377) Thanks @Shrinija17.
- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
@@ -33,14 +98,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke.
- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70.
- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672)
- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351)
- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT.
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
@@ -50,9 +107,6 @@ Docs: https://docs.openclaw.ai
- Web UI: apply button styling to the new-messages indicator.
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.
- Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb.
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.
- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.
@@ -60,13 +114,9 @@ Docs: https://docs.openclaw.ai
- Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
- Cron: reload store data when the store file is recreated or mtime changes.
- Cron: prevent `recomputeNextRuns` from skipping due jobs when timer fires late by reordering `onTimer` flow. (#9823, fixes #9788) Thanks @pycckuu.
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
- Cron: correct announce delivery inference for thread session keys and null delivery inputs. (#9733) Thanks @tyler6204.
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
- Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo.
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
- Security: require gateway auth for Canvas host and A2UI assets. (#9518) Thanks @coygeek.
## 2026.2.2-3

View File

@@ -1 +1 @@
AGENTS.md
AGENTS.md

View File

@@ -44,5 +44,5 @@ USER node
#
# For container platforms requiring external health checks:
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
# 2. Override CMD: ["node","dist/index.js","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "dist/index.js", "gateway", "--allow-unconfigured"]
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

View File

@@ -13,4 +13,8 @@ RUN apt-get update \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
CMD ["sleep", "infinity"]

View File

@@ -23,6 +23,10 @@ RUN apt-get update \
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
EXPOSE 9222 5900 6080
CMD ["openclaw-sandbox-browser"]

View File

@@ -23,9 +23,10 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-clawdbot) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`openclaw onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
@@ -496,46 +497,49 @@ Special thanks to Adam Doppelt for lobster.bot.
Thanks to all clawtributors:
<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/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/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/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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></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/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/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/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/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></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/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/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/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></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/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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></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/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a>
<a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></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/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></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/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/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/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></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/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/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/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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/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/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/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/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/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></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/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></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/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/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/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/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a>
<a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/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/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></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/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/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/lsh411"><img src="https://avatars.githubusercontent.com/u/6801488?v=4&s=48" width="48" height="48" alt="lsh411" title="lsh411"/></a>
<a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="gut-puncture" title="gut-puncture"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></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/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="TinyTb" title="TinyTb"/></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/nicolasstanley"><img src="https://avatars.githubusercontent.com/u/60584925?v=4&s=48" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></a>
<a href="https://github.com/ironbyte-rgb"><img src="https://avatars.githubusercontent.com/u/230665944?v=4&s=48" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></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/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></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/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/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/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a>
<a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></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/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></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/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></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/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/jasonsschin"><img src="https://avatars.githubusercontent.com/u/1456889?v=4&s=48" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
<a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></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/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/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/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/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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></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/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/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a> <a href="https://github.com/shatner"><img src="https://avatars.githubusercontent.com/u/17735435?v=4&s=48" width="48" height="48" alt="shatner" title="shatner"/></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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></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/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></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/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/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></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/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></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/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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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=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/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/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/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/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></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/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></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/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></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/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="18-RAJAT" title="18-RAJAT"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a>
<a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/fujiwara-tofu-shop"><img src="https://avatars.githubusercontent.com/u/259415332?v=4&s=48" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></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/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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></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/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></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/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/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a>
<a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/therealZpoint-bot"><img src="https://avatars.githubusercontent.com/u/258706705?v=4&s=48" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></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/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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></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/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a> <a href="https://github.com/shatner"><img src="https://avatars.githubusercontent.com/u/17735435?v=4&s=48" width="48" height="48" alt="shatner" title="shatner"/></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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></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/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></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/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/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></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/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></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/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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></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=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/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/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/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/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/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></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/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></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/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></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/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="mattqdev" title="mattqdev"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></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/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/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/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></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=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></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/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/bqcfjwhz85-arch"><img src="https://avatars.githubusercontent.com/u/239267175?v=4&s=48" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a>
<a href="https://github.com/search?q=damaozi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="damaozi" title="damaozi"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hclsys" title="hclsys"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a>
<a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></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/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></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/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></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/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a>
<a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></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/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></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/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/deepsoumya617"><img src="https://avatars.githubusercontent.com/u/80877391?v=4&s=48" width="48" height="48" alt="deepsoumya617" title="deepsoumya617"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
<a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></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/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a>
<a href="https://github.com/ichbinlucaskim"><img src="https://avatars.githubusercontent.com/u/125564751?v=4&s=48" width="48" height="48" alt="ichbinlucaskim" title="ichbinlucaskim"/></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=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></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/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></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/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/lsh411"><img src="https://avatars.githubusercontent.com/u/6801488?v=4&s=48" width="48" height="48" alt="lsh411" title="lsh411"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a>
<a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></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/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a>
<a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/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/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/stephenchen2025"><img src="https://avatars.githubusercontent.com/u/218387130?v=4&s=48" width="48" height="48" alt="stephenchen2025" title="stephenchen2025"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></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/wytheme"><img src="https://avatars.githubusercontent.com/u/5009358?v=4&s=48" width="48" height="48" alt="wytheme" title="wytheme"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a>
<a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></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/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/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/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></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/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a>
<a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mattezell"><img src="https://avatars.githubusercontent.com/u/361409?v=4&s=48" width="48" height="48" alt="mattezell" title="mattezell"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></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/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></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/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></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/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></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/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></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/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a>
<a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/deepsoumya617"><img src="https://avatars.githubusercontent.com/u/80877391?v=4&s=48" width="48" height="48" alt="deepsoumya617" title="deepsoumya617"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></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/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/ichbinlucaskim"><img src="https://avatars.githubusercontent.com/u/125564751?v=4&s=48" width="48" height="48" alt="ichbinlucaskim" title="ichbinlucaskim"/></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=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></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/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Omar-Khaleel"><img src="https://avatars.githubusercontent.com/u/240748662?v=4&s=48" width="48" height="48" alt="Omar-Khaleel" title="Omar-Khaleel"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/rafelbev"><img src="https://avatars.githubusercontent.com/u/467120?v=4&s=48" width="48" height="48" alt="rafelbev" title="rafelbev"/></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/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/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a>
<a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/Shrinija17"><img src="https://avatars.githubusercontent.com/u/199155426?v=4&s=48" width="48" height="48" alt="Shrinija17" title="Shrinija17"/></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/stephenchen2025"><img src="https://avatars.githubusercontent.com/u/218387130?v=4&s=48" width="48" height="48" alt="stephenchen2025" title="stephenchen2025"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></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/wytheme"><img src="https://avatars.githubusercontent.com/u/5009358?v=4&s=48" width="48" height="48" alt="wytheme" title="wytheme"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
<a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></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/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/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>

View File

@@ -4,8 +4,13 @@ If you believe you've found a security issue in OpenClaw, please report it priva
## Reporting
- Email: `steipete@gmail.com`
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
For full reporting instructions - including which repo to report to and how - see our [Trust page](https://trust.openclaw.ai).
Include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
## Security & Trust
**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development.
## Bug Bounties

View File

@@ -22,7 +22,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 202602030
versionName = "2026.2.4"
versionName = "2026.2.6"
}
buildTypes {

View File

@@ -1,28 +1,66 @@
# OpenClaw (iOS)
Internal-only SwiftUI app scaffold.
This is an **alpha** iOS app that connects to an OpenClaw Gateway as a `role: node`.
Expect rough edges:
- UI and onboarding are changing quickly.
- Background behavior is not stable yet (foreground app is the supported mode right now).
- Permissions are opt-in and the app should be treated as sensitive while we harden it.
## What It Does
- Connects to a Gateway over `ws://` / `wss://`
- Pairs a new device (approved from your bot)
- Exposes phone services as node commands (camera, location, photos, calendar, reminders, etc; gated by iOS permissions)
- Provides Talk + Chat surfaces (alpha)
## Pairing (Recommended Flow)
If your Gateway has the `device-pair` plugin installed:
1. In Telegram, message your bot: `/pair`
2. Copy the **setup code** message
3. On iOS: OpenClaw → Settings → Gateway → paste setup code → Connect
4. Back in Telegram: `/pair approve`
## Build And Run
Prereqs:
- Xcode (current stable)
- `pnpm`
- `xcodegen`
From the repo root:
## Lint/format (required)
```bash
brew install swiftformat swiftlint
pnpm install
pnpm ios:open
```
## Generate the Xcode project
Then in Xcode:
1. Select the `OpenClaw` scheme
2. Select a simulator or a connected device
3. Run
If you're using a personal Apple Development team, you may need to change the bundle identifier in Xcode to a unique value so signing succeeds.
## Build From CLI
```bash
pnpm ios:build
```
## Tests
```bash
cd apps/ios
xcodegen generate
open OpenClaw.xcodeproj
xcodebuild test -project OpenClaw.xcodeproj -scheme OpenClaw -destination "platform=iOS Simulator,name=iPhone 17"
```
## Shared packages
- `../shared/OpenClawKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
## Shared Code
## fastlane
```bash
brew install fastlane
cd apps/ios
fastlane lanes
```
See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.
- `apps/shared/OpenClawKit` contains the shared transport/types used by the iOS app.

View File

@@ -0,0 +1,167 @@
import EventKit
import Foundation
import OpenClawKit
final class CalendarService: CalendarServicing {
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .event)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Calendar", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
])
}
let (start, end) = Self.resolveRange(
startISO: params.startISO,
endISO: params.endISO)
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
let events = store.events(matching: predicate)
let limit = max(1, min(params.limit ?? 50, 500))
let selected = Array(events.prefix(limit))
let formatter = ISO8601DateFormatter()
let payload = selected.map { event in
OpenClawCalendarEventPayload(
identifier: event.eventIdentifier ?? UUID().uuidString,
title: event.title ?? "(untitled)",
startISO: formatter.string(from: event.startDate),
endISO: formatter.string(from: event.endDate),
isAllDay: event.isAllDay,
location: event.location,
calendarTitle: event.calendar.title)
}
return OpenClawCalendarEventsPayload(events: payload)
}
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .event)
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Calendar", code: 2, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
])
}
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw NSError(domain: "Calendar", code: 3, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required",
])
}
let formatter = ISO8601DateFormatter()
guard let start = formatter.date(from: params.startISO) else {
throw NSError(domain: "Calendar", code: 4, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required",
])
}
guard let end = formatter.date(from: params.endISO) else {
throw NSError(domain: "Calendar", code: 5, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required",
])
}
let event = EKEvent(eventStore: store)
event.title = title
event.startDate = start
event.endDate = end
event.isAllDay = params.isAllDay ?? false
if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
event.location = location
}
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
event.notes = notes
}
event.calendar = try Self.resolveCalendar(
store: store,
calendarId: params.calendarId,
calendarTitle: params.calendarTitle)
try store.save(event, span: .thisEvent)
let payload = OpenClawCalendarEventPayload(
identifier: event.eventIdentifier ?? UUID().uuidString,
title: event.title ?? title,
startISO: formatter.string(from: event.startDate),
endISO: formatter.string(from: event.endDate),
isAllDay: event.isAllDay,
location: event.location,
calendarTitle: event.calendar.title)
return OpenClawCalendarAddPayload(event: payload)
}
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
switch status {
case .authorized:
return true
case .notDetermined:
// Dont prompt during node.invoke; prompts block the invoke and lead to timeouts.
return false
case .restricted, .denied:
return false
case .fullAccess:
return true
case .writeOnly:
return false
@unknown default:
return false
}
}
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
switch status {
case .authorized, .fullAccess, .writeOnly:
return true
case .notDetermined:
// Dont prompt during node.invoke; prompts block the invoke and lead to timeouts.
return false
case .restricted, .denied:
return false
@unknown default:
return false
}
}
private static func resolveCalendar(
store: EKEventStore,
calendarId: String?,
calendarTitle: String?) throws -> EKCalendar
{
if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
let calendar = store.calendar(withIdentifier: id)
{
return calendar
}
if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
if let calendar = store.calendars(for: .event).first(where: {
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
}) {
return calendar
}
throw NSError(domain: "Calendar", code: 6, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)",
])
}
if let fallback = store.defaultCalendarForNewEvents {
return fallback
}
throw NSError(domain: "Calendar", code: 7, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar",
])
}
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
let formatter = ISO8601DateFormatter()
let start = startISO.flatMap { formatter.date(from: $0) } ?? Date()
let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600)
return (start, end)
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
import OpenClawKit
@MainActor
final class NodeCapabilityRouter {
enum RouterError: Error {
case unknownCommand
case handlerUnavailable
}
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
private let handlers: [String: Handler]
init(handlers: [String: Handler]) {
self.handlers = handlers
}
func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard let handler = handlers[request.command] else {
throw RouterError.unknownCommand
}
return try await handler(request)
}
}

View File

@@ -6,14 +6,16 @@ struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: OpenClawChatViewModel
private let userAccent: Color?
private let agentName: String?
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway)
self._viewModel = State(
initialValue: OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport))
self.userAccent = userAccent
self.agentName = agentName
}
var body: some View {
@@ -22,7 +24,7 @@ struct ChatSheet: View {
viewModel: self.viewModel,
showsSessionSwitcher: true,
userAccent: self.userAccent)
.navigationTitle("Chat")
.navigationTitle(self.chatTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -36,4 +38,10 @@ struct ChatSheet: View {
}
}
}
private var chatTitle: String {
let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "Chat" }
return "Chat (\(trimmed))"
}
}

View File

@@ -0,0 +1,212 @@
import Contacts
import Foundation
import OpenClawKit
final class ContactsService: ContactsServicing {
private static var payloadKeys: [CNKeyDescriptor] {
[
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
]
}
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
let store = CNContactStore()
let status = CNContactStore.authorizationStatus(for: .contacts)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
let limit = max(1, min(params.limit ?? 25, 200))
var contacts: [CNContact] = []
if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
let predicate = CNContact.predicateForContacts(matchingName: query)
contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
} else {
let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
try store.enumerateContacts(with: request) { contact, stop in
contacts.append(contact)
if contacts.count >= limit {
stop.pointee = true
}
}
}
let sliced = Array(contacts.prefix(limit))
let payload = sliced.map { Self.payload(from: $0) }
return OpenClawContactsSearchPayload(contacts: payload)
}
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
let store = CNContactStore()
let status = CNContactStore.authorizationStatus(for: .contacts)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
let emails = Self.normalizeStrings(params.emails, lowercased: true)
let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
let hasOrg = !(organizationName ?? "").isEmpty
let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
guard hasName || hasOrg || hasDetails else {
throw NSError(domain: "Contacts", code: 2, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
])
}
if !phoneNumbers.isEmpty || !emails.isEmpty {
if let existing = try Self.findExistingContact(
store: store,
phoneNumbers: phoneNumbers,
emails: emails)
{
return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
}
}
let contact = CNMutableContact()
contact.givenName = givenName ?? ""
contact.familyName = familyName ?? ""
contact.organizationName = organizationName ?? ""
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
contact.givenName = displayName
}
contact.phoneNumbers = phoneNumbers.map {
CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
}
contact.emailAddresses = emails.map {
CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
}
let save = CNSaveRequest()
save.add(contact, toContainerWithIdentifier: nil)
try store.execute(save)
let persisted: CNContact
if !contact.identifier.isEmpty {
persisted = try store.unifiedContact(
withIdentifier: contact.identifier,
keysToFetch: Self.payloadKeys)
} else {
persisted = contact
}
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
}
private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
switch status {
case .authorized, .limited:
return true
case .notDetermined:
// Dont prompt during node.invoke; the caller should instruct the user to grant permission.
// Prompts block the invoke and lead to timeouts in headless flows.
return false
case .restricted, .denied:
return false
@unknown default:
return false
}
}
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
(values ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { lowercased ? $0.lowercased() : $0 }
}
private static func findExistingContact(
store: CNContactStore,
phoneNumbers: [String],
emails: [String]) throws -> CNContact?
{
if phoneNumbers.isEmpty && emails.isEmpty {
return nil
}
var matches: [CNContact] = []
for phone in phoneNumbers {
let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
matches.append(contentsOf: contacts)
}
for email in emails {
let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
matches.append(contentsOf: contacts)
}
return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
}
private static func matchContacts(
contacts: [CNContact],
phoneNumbers: [String],
emails: [String]) -> CNContact?
{
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
var seen = Set<String>()
for contact in contacts {
guard seen.insert(contact.identifier).inserted else { continue }
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
return contact
}
if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
return contact
}
}
return nil
}
private static func normalizePhone(_ phone: String) -> String {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
let normalized = String(String.UnicodeScalarView(digits))
return normalized.isEmpty ? trimmed : normalized
}
private static func payload(from contact: CNContact) -> OpenClawContactPayload {
OpenClawContactPayload(
identifier: contact.identifier,
displayName: CNContactFormatter.string(from: contact, style: .fullName)
?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
givenName: contact.givenName,
familyName: contact.familyName,
organizationName: contact.organizationName,
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
emails: contact.emailAddresses.map { String($0.value) })
}
#if DEBUG
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
}
#endif
}

View File

@@ -0,0 +1,87 @@
import Foundation
import OpenClawKit
import UIKit
final class DeviceStatusService: DeviceStatusServicing {
private let networkStatus: NetworkStatusService
init(networkStatus: NetworkStatusService = NetworkStatusService()) {
self.networkStatus = networkStatus
}
func status() async throws -> OpenClawDeviceStatusPayload {
let battery = self.batteryStatus()
let thermal = self.thermalStatus()
let storage = self.storageStatus()
let network = await self.networkStatus.currentStatus()
let uptime = ProcessInfo.processInfo.systemUptime
return OpenClawDeviceStatusPayload(
battery: battery,
thermal: thermal,
storage: storage,
network: network,
uptimeSeconds: uptime)
}
func info() -> OpenClawDeviceInfoPayload {
let device = UIDevice.current
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
let locale = Locale.preferredLanguages.first ?? Locale.current.identifier
return OpenClawDeviceInfoPayload(
deviceName: device.name,
modelIdentifier: Self.modelIdentifier(),
systemName: device.systemName,
systemVersion: device.systemVersion,
appVersion: appVersion,
appBuild: appBuild,
locale: locale)
}
private func batteryStatus() -> OpenClawBatteryStatusPayload {
let device = UIDevice.current
device.isBatteryMonitoringEnabled = true
let level = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil
let state: OpenClawBatteryState = switch device.batteryState {
case .charging: .charging
case .full: .full
case .unplugged: .unplugged
case .unknown: .unknown
@unknown default: .unknown
}
return OpenClawBatteryStatusPayload(
level: level,
state: state,
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
}
private func thermalStatus() -> OpenClawThermalStatusPayload {
let state: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
case .nominal: .nominal
case .fair: .fair
case .serious: .serious
case .critical: .critical
@unknown default: .nominal
}
return OpenClawThermalStatusPayload(state: state)
}
private func storageStatus() -> OpenClawStorageStatusPayload {
let attrs = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
let total = (attrs[.systemSize] as? NSNumber)?.int64Value ?? 0
let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
let used = max(0, total - free)
return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used)
}
private static func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Network
import OpenClawKit
final class NetworkStatusService: @unchecked Sendable {
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
await withCheckedContinuation { cont in
let monitor = NWPathMonitor()
let queue = DispatchQueue(label: "bot.molt.ios.network-status")
let state = NetworkStatusState()
monitor.pathUpdateHandler = { path in
guard state.markCompleted() else { return }
monitor.cancel()
cont.resume(returning: Self.payload(from: path))
}
monitor.start(queue: queue)
queue.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
guard state.markCompleted() else { return }
monitor.cancel()
cont.resume(returning: Self.fallbackPayload())
}
}
}
private static func payload(from path: NWPath) -> OpenClawNetworkStatusPayload {
let status: OpenClawNetworkPathStatus = switch path.status {
case .satisfied: .satisfied
case .requiresConnection: .requiresConnection
case .unsatisfied: .unsatisfied
@unknown default: .unsatisfied
}
var interfaces: [OpenClawNetworkInterfaceType] = []
if path.usesInterfaceType(.wifi) { interfaces.append(.wifi) }
if path.usesInterfaceType(.cellular) { interfaces.append(.cellular) }
if path.usesInterfaceType(.wiredEthernet) { interfaces.append(.wired) }
if interfaces.isEmpty { interfaces.append(.other) }
return OpenClawNetworkStatusPayload(
status: status,
isExpensive: path.isExpensive,
isConstrained: path.isConstrained,
interfaces: interfaces)
}
private static func fallbackPayload() -> OpenClawNetworkStatusPayload {
OpenClawNetworkStatusPayload(
status: .unsatisfied,
isExpensive: false,
isConstrained: false,
interfaces: [.other])
}
}
private final class NetworkStatusState: @unchecked Sendable {
private let lock = NSLock()
private var completed = false
func markCompleted() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
if self.completed { return false }
self.completed = true
return true
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
import UIKit
enum NodeDisplayName {
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
static func isGeneric(_ name: String) -> Bool {
Self.genericNames.contains(name)
}
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
switch interfaceIdiom {
case .phone:
return "iPhone Node"
case .pad:
return "iPad Node"
default:
return "iOS Node"
}
}
static func resolve(
existing: String?,
deviceName: String,
interfaceIdiom: UIUserInterfaceIdiom
) -> String {
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
return trimmedExisting
}
let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines)
if let normalized = Self.normalizedDeviceName(trimmedDevice) {
return normalized
}
return Self.defaultValue(for: interfaceIdiom)
}
private static func normalizedDeviceName(_ deviceName: String) -> String? {
guard !deviceName.isEmpty else { return nil }
let lower = deviceName.lowercased()
if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") {
return deviceName
}
return nil
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
import OpenClawKit
/// Single source of truth for "how we connect" to the current gateway.
///
/// The iOS app maintains two WebSocket sessions to the same gateway:
/// - a `role=node` session for device capabilities (`node.invoke.*`)
/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.)
///
/// Both sessions should derive all connection inputs from this config so we
/// don't accidentally persist gateway-scoped state under different keys.
struct GatewayConnectConfig: Sendable {
let url: URL
let stableID: String
let tls: GatewayTLSParams?
let token: String?
let password: String?
let nodeOptions: GatewayConnectOptions
/// Stable, non-empty identifier used for gateway-scoped persistence keys.
/// If the caller doesn't provide a stableID, fall back to URL identity.
var effectiveStableID: String {
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return self.url.absoluteString }
return trimmed
}
}

View File

@@ -1,8 +1,15 @@
import OpenClawKit
import Darwin
import AVFoundation
import Contacts
import CoreLocation
import CoreMotion
import EventKit
import Foundation
import OpenClawKit
import Network
import Observation
import Photos
import ReplayKit
import Speech
import SwiftUI
import UIKit
@@ -42,8 +49,10 @@ final class GatewayConnectionController {
self.discovery.stop()
case .active, .inactive:
self.discovery.start()
self.attemptAutoReconnectIfNeeded()
@unknown default:
self.discovery.start()
self.attemptAutoReconnectIfNeeded()
}
}
@@ -60,6 +69,11 @@ final class GatewayConnectionController {
port: port,
useTLS: tlsParams?.required == true)
else { return }
GatewaySettingsStore.saveLastGatewayConnection(
host: host,
port: port,
useTLS: tlsParams?.required == true,
stableID: gateway.stableID)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
@@ -74,13 +88,24 @@ final class GatewayConnectionController {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let stableID = self.manualStableID(host: host, port: port)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
let resolvedUseTLS = useTLS
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
else { return }
let stableID = self.manualStableID(host: host, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: host))
guard let url = self.buildGatewayURL(
host: host,
port: port,
port: resolvedPort,
useTLS: tlsParams?.required == true)
else { return }
GatewaySettingsStore.saveLastGatewayConnection(
host: host,
port: resolvedPort,
useTLS: tlsParams?.required == true,
stableID: stableID)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
@@ -90,6 +115,38 @@ final class GatewayConnectionController {
password: password)
}
func connectLastKnown() async {
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let resolvedUseTLS = last.useTLS
let tlsParams = self.resolveManualTLSParams(
stableID: last.stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: last.host))
guard let url = self.buildGatewayURL(
host: last.host,
port: last.port,
useTLS: tlsParams?.required == true)
else { return }
if resolvedUseTLS != last.useTLS {
GatewaySettingsStore.saveLastGatewayConnection(
host: last.host,
port: last.port,
useTLS: resolvedUseTLS,
stableID: last.stableID)
}
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: last.stableID,
tls: tlsParams,
token: token,
password: password)
}
private func updateFromDiscovery() {
let newGateways = self.discovery.gateways
self.gateways = newGateways
@@ -119,6 +176,7 @@ final class GatewayConnectionController {
guard appModel.gatewayServerName == nil else { return }
let defaults = UserDefaults.standard
guard defaults.bool(forKey: "gateway.autoconnect") else { return }
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
let instanceId = defaults.string(forKey: "node.instanceId")?
@@ -134,11 +192,19 @@ final class GatewayConnectionController {
guard !manualHost.isEmpty else { return }
let manualPort = defaults.integer(forKey: "gateway.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18789
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost)
guard let resolvedPort = self.resolveManualPort(
host: manualHost,
port: manualPort,
useTLS: resolvedUseTLS)
else { return }
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: manualHost))
guard let url = self.buildGatewayURL(
host: manualHost,
@@ -156,30 +222,80 @@ final class GatewayConnectionController {
return
}
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
let tlsParams = self.resolveManualTLSParams(
stableID: lastKnown.stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
guard let url = self.buildGatewayURL(
host: lastKnown.host,
port: lastKnown.port,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: lastKnown.stableID,
tls: tlsParams,
token: token,
password: password)
return
}
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
guard let targetStableID = candidates.first(where: { id in
if let targetStableID = candidates.first(where: { id in
self.gateways.contains(where: { $0.stableID == id })
}) else { return }
}) {
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
guard let host = self.resolveGatewayHost(target) else { return }
let port = target.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
guard let host = self.resolveGatewayHost(target) else { return }
let port = target.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: target.stableID,
tls: tlsParams,
token: token,
password: password)
return
}
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: target.stableID,
tls: tlsParams,
token: token,
password: password)
if self.gateways.count == 1, let gateway = self.gateways.first {
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: gateway.stableID,
tls: tlsParams,
token: token,
password: password)
return
}
}
private func attemptAutoReconnectIfNeeded() {
guard let appModel = self.appModel else { return }
guard appModel.gatewayAutoReconnectEnabled else { return }
// Avoid starting duplicate connect loops while a prior config is active.
guard appModel.activeGatewayConnectConfig == nil else { return }
guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return }
self.didAutoConnect = false
self.maybeAutoConnect()
}
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
@@ -205,20 +321,21 @@ final class GatewayConnectionController {
password: String?)
{
guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
Task { [weak self] in
guard let self else { return }
Task { [weak appModel] in
guard let appModel else { return }
await MainActor.run {
appModel.gatewayStatusText = "Connecting…"
}
appModel.connectToGateway(
let cfg = GatewayConnectConfig(
url: url,
gatewayStableID: gatewayStableID,
stableID: gatewayStableID,
tls: tls,
token: token,
password: password,
connectOptions: connectOptions)
nodeOptions: connectOptions)
appModel.applyGatewayConnectConfig(cfg)
}
}
@@ -237,13 +354,17 @@ final class GatewayConnectionController {
return nil
}
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
private func resolveManualTLSParams(
stableID: String,
tlsEnabled: Bool,
allowTOFUReset: Bool = false) -> GatewayTLSParams?
{
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: stored == nil,
allowTOFU: stored == nil || allowTOFUReset,
storeKey: stableID)
}
@@ -251,12 +372,12 @@ final class GatewayConnectionController {
}
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
return tailnet
}
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
return nil
}
@@ -269,38 +390,69 @@ final class GatewayConnectionController {
return components.url
}
private func shouldForceTLS(host: String) -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.isEmpty { return false }
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
}
private func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
}
private func makeConnectOptions() -> GatewayConnectOptions {
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
return GatewayConnectOptions(
role: "node",
scopes: [],
caps: self.currentCaps(),
commands: self.currentCommands(),
permissions: [:],
clientId: "openclaw-ios",
permissions: self.currentPermissions(),
clientId: resolvedClientId,
clientMode: "node",
clientDisplayName: displayName)
}
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
if let stableID,
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
return override
}
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
.trimmingCharacters(in: .whitespacesAndNewlines)
if manualClientId?.isEmpty == false {
return manualClientId!
}
return "openclaw-ios"
}
private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
if port > 0 {
return port <= 65535 ? port : nil
}
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return nil }
if useTLS && self.shouldForceTLS(host: trimmedHost) {
return 443
}
return 18789
}
private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !existing.isEmpty, existing != "iOS Node" { return existing }
let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
let candidate = deviceName.isEmpty ? "iOS Node" : deviceName
if existing.isEmpty || existing == "iOS Node" {
defaults.set(candidate, forKey: key)
let existingRaw = defaults.string(forKey: key)
let resolved = NodeDisplayName.resolve(
existing: existingRaw,
deviceName: UIDevice.current.name,
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if existing.isEmpty || NodeDisplayName.isGeneric(existing) {
defaults.set(resolved, forKey: key)
}
return candidate
return resolved
}
private func currentCaps() -> [String] {
@@ -320,6 +472,15 @@ final class GatewayConnectionController {
let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
caps.append(OpenClawCapability.device.rawValue)
caps.append(OpenClawCapability.photos.rawValue)
caps.append(OpenClawCapability.contacts.rawValue)
caps.append(OpenClawCapability.calendar.rawValue)
caps.append(OpenClawCapability.reminders.rawValue)
if Self.motionAvailable() {
caps.append(OpenClawCapability.motion.rawValue)
}
return caps
}
@@ -335,10 +496,11 @@ final class GatewayConnectionController {
OpenClawCanvasA2UICommand.reset.rawValue,
OpenClawScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.execApprovalsGet.rawValue,
OpenClawSystemCommand.execApprovalsSet.rawValue,
OpenClawChatCommand.push.rawValue,
OpenClawTalkCommand.pttStart.rawValue,
OpenClawTalkCommand.pttStop.rawValue,
OpenClawTalkCommand.pttCancel.rawValue,
OpenClawTalkCommand.pttOnce.rawValue,
]
let caps = Set(self.currentCaps())
@@ -350,10 +512,76 @@ final class GatewayConnectionController {
if caps.contains(OpenClawCapability.location.rawValue) {
commands.append(OpenClawLocationCommand.get.rawValue)
}
if caps.contains(OpenClawCapability.device.rawValue) {
commands.append(OpenClawDeviceCommand.status.rawValue)
commands.append(OpenClawDeviceCommand.info.rawValue)
}
if caps.contains(OpenClawCapability.photos.rawValue) {
commands.append(OpenClawPhotosCommand.latest.rawValue)
}
if caps.contains(OpenClawCapability.contacts.rawValue) {
commands.append(OpenClawContactsCommand.search.rawValue)
commands.append(OpenClawContactsCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.calendar.rawValue) {
commands.append(OpenClawCalendarCommand.events.rawValue)
commands.append(OpenClawCalendarCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.reminders.rawValue) {
commands.append(OpenClawRemindersCommand.list.rawValue)
commands.append(OpenClawRemindersCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.motion.rawValue) {
commands.append(OpenClawMotionCommand.activity.rawValue)
commands.append(OpenClawMotionCommand.pedometer.rawValue)
}
return commands
}
private func currentPermissions() -> [String: Bool] {
var permissions: [String: Bool] = [:]
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
permissions["location"] = Self.isLocationAuthorized(
status: CLLocationManager().authorizationStatus)
&& CLLocationManager.locationServicesEnabled()
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
permissions["photos"] = photoStatus == .authorized || photoStatus == .limited
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
permissions["calendar"] =
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
permissions["reminders"] =
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
let motionStatus = CMMotionActivityManager.authorizationStatus()
let pedometerStatus = CMPedometer.authorizationStatus()
permissions["motion"] =
motionStatus == .authorized || pedometerStatus == .authorized
return permissions
}
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
return true
default:
return false
}
}
private static func motionAvailable() -> Bool {
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
let name = switch UIDevice.current.userInterfaceIdiom {
@@ -407,6 +635,10 @@ extension GatewayConnectionController {
self.currentCommands()
}
func _test_currentPermissions() -> [String: Bool] {
self.currentPermissions()
}
func _test_platformString() -> String {
self.platformString()
}

View File

@@ -0,0 +1,85 @@
import Foundation
import OpenClawKit
@MainActor
final class GatewayHealthMonitor {
struct Config: Sendable {
var intervalSeconds: Double
var timeoutSeconds: Double
var maxFailures: Int
}
private let config: Config
private let sleep: @Sendable (UInt64) async -> Void
private var task: Task<Void, Never>?
init(
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds)
}
) {
self.config = config
self.sleep = sleep
}
func start(
check: @escaping @Sendable () async throws -> Bool,
onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void)
{
self.stop()
let config = self.config
let sleep = self.sleep
self.task = Task { @MainActor in
var failures = 0
while !Task.isCancelled {
let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds)
if ok {
failures = 0
} else {
failures += 1
if failures >= max(1, config.maxFailures) {
await onFailure(failures)
failures = 0
}
}
if Task.isCancelled { break }
let interval = max(0.0, config.intervalSeconds)
let nanos = UInt64(interval * 1_000_000_000)
if nanos > 0 {
await sleep(nanos)
} else {
await Task.yield()
}
}
}
}
func stop() {
self.task?.cancel()
self.task = nil
}
private static func runCheck(
check: @escaping @Sendable () async throws -> Bool,
timeoutSeconds: Double) async -> Bool
{
let timeout = max(0.0, timeoutSeconds)
if timeout == 0 {
return (try? await check()) ?? false
}
do {
let timeoutError = NSError(
domain: "GatewayHealthMonitor",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "health check timed out"])
return try await AsyncTimeout.withTimeout(
seconds: timeout,
onTimeout: { timeoutError },
operation: check)
} catch {
return false
}
}
}

View File

@@ -1,4 +1,5 @@
import Foundation
import os
enum GatewaySettingsStore {
private static let gatewayService = "ai.openclaw.gateway"
@@ -12,6 +13,12 @@ enum GatewaySettingsStore {
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId."
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
@@ -107,6 +114,71 @@ enum GatewaySettingsStore {
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
let defaults = UserDefaults.standard
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
}
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
let defaults = UserDefaults.standard
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
let value = UserDefaults.standard.string(forKey: key)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return }
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmedClientId.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(trimmedClientId, forKey: key)
}
}
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }
let key = self.selectedAgentDefaultsPrefix + trimmedID
let value = UserDefaults.standard.string(forKey: key)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return }
let key = self.selectedAgentDefaultsPrefix + trimmedID
let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmedAgentId.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(trimmedAgentId, forKey: key)
}
}
private static func gatewayTokenAccount(instanceId: String) -> String {
"gateway-token.\(instanceId)"
}
@@ -175,3 +247,101 @@ enum GatewaySettingsStore {
}
}
enum GatewayDiagnostics {
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
private static let maxLogBytes: Int64 = 512 * 1024
private static let keepLogBytes: Int64 = 256 * 1024
private static let logSizeCheckEveryWrites = 50
nonisolated(unsafe) private static var logWritesSinceCheck = 0
private static var fileURL: URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
.appendingPathComponent("openclaw-gateway.log")
}
private static func truncateLogIfNeeded(url: URL) {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let sizeNumber = attrs[.size] as? NSNumber
else { return }
let size = sizeNumber.int64Value
guard size > self.maxLogBytes else { return }
do {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let start = max(Int64(0), size - self.keepLogBytes)
try handle.seek(toOffset: UInt64(start))
var tail = try handle.readToEnd() ?? Data()
// If we truncated mid-line, drop the first partial line so logs remain readable.
if start > 0, let nl = tail.firstIndex(of: 10) {
let next = tail.index(after: nl)
if next < tail.endIndex {
tail = tail.suffix(from: next)
} else {
tail = Data()
}
}
try tail.write(to: url, options: .atomic)
} catch {
// Best-effort only.
}
}
private static func appendToLog(url: URL, data: Data) {
if FileManager.default.fileExists(atPath: url.path) {
if let handle = try? FileHandle(forWritingTo: url) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
}
} else {
try? data.write(to: url, options: .atomic)
}
}
static func bootstrap() {
guard let url = fileURL else { return }
queue.async {
self.truncateLogIfNeeded(url: url)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let timestamp = formatter.string(from: Date())
let line = "[\(timestamp)] gateway diagnostics started\n"
if let data = line.data(using: .utf8) {
self.appendToLog(url: url, data: data)
}
}
}
static func log(_ message: String) {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let timestamp = formatter.string(from: Date())
let line = "[\(timestamp)] \(message)"
logger.info("\(line, privacy: .public)")
guard let url = fileURL else { return }
queue.async {
self.logWritesSinceCheck += 1
if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites {
self.logWritesSinceCheck = 0
self.truncateLogIfNeeded(url: url)
}
let entry = line + "\n"
if let data = entry.data(using: .utf8) {
self.appendToLog(url: url, data: data)
}
}
}
static func reset() {
guard let url = fileURL else { return }
queue.async {
try? FileManager.default.removeItem(at: url)
}
}
}

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.4</string>
<string>2026.2.6</string>
<key>CFBundleVersion</key>
<string>20260202</string>
<key>NSAppTransportSecurity</key>

View File

@@ -0,0 +1,164 @@
import Foundation
import Photos
import OpenClawKit
import UIKit
final class PhotoLibraryService: PhotosServicing {
// The gateway WebSocket has a max payload size; returning large base64 blobs
// can cause the gateway to close the connection. Keep photo payloads small
// enough to safely fit in a single RPC frame.
//
// This is a transport constraint (not a security policy). If callers need
// full-resolution media, we should switch to an HTTP media handle flow.
private static let maxTotalBase64Chars = 340 * 1024
private static let maxPerPhotoBase64Chars = 300 * 1024
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload {
let status = await Self.ensureAuthorization()
guard status == .authorized || status == .limited else {
throw NSError(domain: "Photos", code: 1, userInfo: [
NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
])
}
let limit = max(1, min(params.limit ?? 1, 20))
let fetchOptions = PHFetchOptions()
fetchOptions.fetchLimit = limit
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions)
var results: [OpenClawPhotoPayload] = []
var remainingBudget = Self.maxTotalBase64Chars
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85
let formatter = ISO8601DateFormatter()
assets.enumerateObjects { asset, _, stop in
if results.count >= limit { stop.pointee = true; return }
if let payload = try? Self.renderAsset(
asset,
maxWidth: maxWidth,
quality: quality,
formatter: formatter)
{
// Keep the entire response under the gateway WS max payload.
if payload.base64.count > remainingBudget {
stop.pointee = true
return
}
remainingBudget -= payload.base64.count
results.append(payload)
}
}
return OpenClawPhotosLatestPayload(photos: results)
}
private static func ensureAuthorization() async -> PHAuthorizationStatus {
// Dont prompt during node.invoke; prompts block the invoke and lead to timeouts.
PHPhotoLibrary.authorizationStatus(for: .readWrite)
}
private static func renderAsset(
_ asset: PHAsset,
maxWidth: Int,
quality: Double,
formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload
{
let manager = PHImageManager.default()
let options = PHImageRequestOptions()
options.isSynchronous = true
options.isNetworkAccessAllowed = true
options.deliveryMode = .highQualityFormat
let targetSize: CGSize = {
guard maxWidth > 0 else { return PHImageManagerMaximumSize }
let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth))
let width = CGFloat(maxWidth)
return CGSize(width: width, height: width * aspect)
}()
var image: UIImage?
manager.requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFit,
options: options)
{ result, _ in
image = result
}
guard let image else {
throw NSError(domain: "Photos", code: 2, userInfo: [
NSLocalizedDescriptionKey: "photo load failed",
])
}
let (data, finalImage) = try encodeJpegUnderBudget(
image: image,
quality: quality,
maxBase64Chars: maxPerPhotoBase64Chars)
let created = asset.creationDate.map { formatter.string(from: $0) }
return OpenClawPhotoPayload(
format: "jpeg",
base64: data.base64EncodedString(),
width: Int(finalImage.size.width),
height: Int(finalImage.size.height),
createdAt: created)
}
private static func encodeJpegUnderBudget(
image: UIImage,
quality: Double,
maxBase64Chars: Int) throws -> (Data, UIImage)
{
var currentImage = image
var currentQuality = max(0.1, min(1.0, quality))
// Try lowering JPEG quality first, then downscale if needed.
for _ in 0..<10 {
guard let data = currentImage.jpegData(compressionQuality: currentQuality) else {
throw NSError(domain: "Photos", code: 3, userInfo: [
NSLocalizedDescriptionKey: "photo encode failed",
])
}
let base64Len = ((data.count + 2) / 3) * 4
if base64Len <= maxBase64Chars {
return (data, currentImage)
}
if currentQuality > 0.35 {
currentQuality = max(0.25, currentQuality - 0.15)
continue
}
// Downscale by ~25% each step once quality is low.
let newWidth = max(240, currentImage.size.width * 0.75)
if newWidth >= currentImage.size.width {
break
}
currentImage = resize(image: currentImage, targetWidth: newWidth)
}
throw NSError(domain: "Photos", code: 4, userInfo: [
NSLocalizedDescriptionKey: "photo too large for gateway transport; try smaller maxWidth/quality",
])
}
private static func resize(image: UIImage, targetWidth: CGFloat) -> UIImage {
let size = image.size
if size.width <= 0 || size.height <= 0 || targetWidth <= 0 {
return image
}
let scale = targetWidth / size.width
let targetSize = CGSize(width: targetWidth, height: max(1, size.height * scale))
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}

View File

@@ -0,0 +1,97 @@
import Foundation
import Network
import os
extension NodeAppModel {
func _test_resolveA2UIHostURL() async -> String? {
await self.resolveA2UIHostURL()
}
func resolveA2UIHostURL() async -> String? {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
if let host = base.host, Self.isLoopbackHost(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
}
private static func isLoopbackHost(_ host: String) -> Bool {
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return true }
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
return true
}
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
return true
}
return false
}
func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
await MainActor.run {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
return
}
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
// Avoid navigating the WKWebView to an unreachable host: it leaves a persistent
// "could not connect to the server" overlay even when the gateway is connected.
if let url = URL(string: a2uiUrl),
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
{
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
} else {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
}
}
func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
guard portInt >= 1, portInt <= 65535 else { return false }
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
let endpointHost = NWEndpoint.Host(host)
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
return await withCheckedContinuation { cont in
let queue = DispatchQueue(label: "a2ui.preflight")
let finished = OSAllocatedUnfairLock(initialState: false)
let finish: @Sendable (Bool) -> Void = { ok in
let shouldResume = finished.withLock { flag -> Bool in
if flag { return false }
flag = true
return true
}
guard shouldResume else { return }
connection.cancel()
cont.resume(returning: ok)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(true)
case .failed, .cancelled:
finish(false)
default:
break
}
}
connection.start(queue: queue)
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import CoreMotion
import Foundation
import OpenClawKit
final class MotionService: MotionServicing {
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload {
guard CMMotionActivityManager.isActivityAvailable() else {
throw NSError(domain: "Motion", code: 1, userInfo: [
NSLocalizedDescriptionKey: "MOTION_UNAVAILABLE: activity not supported on this device",
])
}
let auth = CMMotionActivityManager.authorizationStatus()
guard auth == .authorized else {
throw NSError(domain: "Motion", code: 3, userInfo: [
NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission",
])
}
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
let limit = max(1, min(params.limit ?? 200, 1000))
let manager = CMMotionActivityManager()
let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in
manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in
if let error {
cont.resume(throwing: error)
} else {
let formatter = ISO8601DateFormatter()
let sliced = Array((activity ?? []).suffix(limit))
let entries = sliced.map { entry in
OpenClawMotionActivityEntry(
startISO: formatter.string(from: entry.startDate),
endISO: formatter.string(from: end),
confidence: Self.confidenceString(entry.confidence),
isWalking: entry.walking,
isRunning: entry.running,
isCycling: entry.cycling,
isAutomotive: entry.automotive,
isStationary: entry.stationary,
isUnknown: entry.unknown)
}
cont.resume(returning: entries)
}
}
}
return OpenClawMotionActivityPayload(activities: mapped)
}
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload {
guard CMPedometer.isStepCountingAvailable() else {
throw NSError(domain: "Motion", code: 2, userInfo: [
NSLocalizedDescriptionKey: "PEDOMETER_UNAVAILABLE: step counting not supported",
])
}
let auth = CMPedometer.authorizationStatus()
guard auth == .authorized else {
throw NSError(domain: "Motion", code: 4, userInfo: [
NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission",
])
}
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
let pedometer = CMPedometer()
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<OpenClawPedometerPayload, Error>) in
pedometer.queryPedometerData(from: start, to: end) { data, error in
if let error {
cont.resume(throwing: error)
} else {
let formatter = ISO8601DateFormatter()
let payload = OpenClawPedometerPayload(
startISO: formatter.string(from: start),
endISO: formatter.string(from: end),
steps: data?.numberOfSteps.intValue,
distanceMeters: data?.distance?.doubleValue,
floorsAscended: data?.floorsAscended?.intValue,
floorsDescended: data?.floorsDescended?.intValue)
cont.resume(returning: payload)
}
}
}
return payload
}
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
let formatter = ISO8601DateFormatter()
let start = startISO.flatMap { formatter.date(from: $0) } ?? Calendar.current.startOfDay(for: Date())
let end = endISO.flatMap { formatter.date(from: $0) } ?? Date()
return (start, end)
}
private static func confidenceString(_ confidence: CMMotionActivityConfidence) -> String {
switch confidence {
case .low: "low"
case .medium: "medium"
case .high: "high"
@unknown default: "unknown"
}
}
}

View File

@@ -0,0 +1,389 @@
import Foundation
import SwiftUI
struct GatewayOnboardingView: View {
var body: some View {
NavigationStack {
List {
Section {
Text("Connect to your gateway to get started.")
.foregroundStyle(.secondary)
}
Section {
NavigationLink("Auto detect") {
AutoDetectStep()
}
NavigationLink("Manual entry") {
ManualEntryStep()
}
}
}
.navigationTitle("Connect Gateway")
}
}
}
private struct AutoDetectStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section {
Text("Well scan for gateways on your network and connect automatically when we find one.")
.foregroundStyle(.secondary)
}
Section("Connection status") {
ConnectionStatusBox(
statusLines: self.connectionStatusLines(),
secondaryLine: self.connectStatusText)
}
Section {
Button("Retry") {
self.resetConnectionState()
self.triggerAutoConnect()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Auto detect")
.onAppear { self.triggerAutoConnect() }
.onChange(of: self.gatewayController.gateways) { _, _ in
self.triggerAutoConnect()
}
}
private func triggerAutoConnect() {
guard self.appModel.gatewayServerName == nil else { return }
guard self.connectingGatewayID == nil else { return }
guard let candidate = self.autoCandidate() else { return }
self.connectingGatewayID = candidate.id
Task {
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(candidate)
}
}
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
{
return match
}
if !lastDiscovered.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
{
return match
}
if self.gatewayController.gateways.count == 1 {
return self.gatewayController.gateways.first
}
return nil
}
private func connectionStatusLines() -> [String] {
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
}
private func resetConnectionState() {
self.appModel.disconnectGateway()
self.connectStatusText = nil
self.connectingGatewayID = nil
}
}
private struct ManualEntryStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@State private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualHost: String = ""
@State private var manualPortText: String = ""
@State private var manualUseTLS: Bool = true
@State private var manualToken: String = ""
@State private var manualPassword: String = ""
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section("Setup code") {
Text("Use /pair in your bot to get a setup code.")
.font(.footnote)
.foregroundStyle(.secondary)
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Apply setup code") {
self.applySetupCode()
}
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let setupStatusText, !setupStatusText.isEmpty {
Text(setupStatusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualUseTLS)
TextField("Gateway token", text: self.$manualToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway password", text: self.$manualPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section("Connection status") {
ConnectionStatusBox(
statusLines: self.connectionStatusLines(),
secondaryLine: self.connectStatusText)
}
Section {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(self.connectingGatewayID != nil)
Button("Retry") {
self.resetConnectionState()
self.resetManualForm()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Manual entry")
}
private func connectManual() async {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatusText = "Failed: host required"
return
}
if let port = self.manualPortValue(), !(1...65535).contains(port) {
self.connectStatusText = "Failed: invalid port"
return
}
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.manual.enabled")
defaults.set(host, forKey: "gateway.manual.host")
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
if let instanceId = defaults.string(forKey: "node.instanceId")?.trimmingCharacters(in: .whitespacesAndNewlines),
!instanceId.isEmpty
{
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedToken.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
}
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
}
self.connectingGatewayID = "manual"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectManual(
host: host,
port: self.manualPortValue() ?? 0,
useTLS: self.manualUseTLS)
}
private func manualPortValue() -> Int? {
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return Int(trimmed.filter { $0.isNumber })
}
private func connectionStatusLines() -> [String] {
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
}
private func resetConnectionState() {
self.appModel.disconnectGateway()
self.connectStatusText = nil
self.connectingGatewayID = nil
}
private func resetManualForm() {
self.setupCode = ""
self.setupStatusText = nil
self.manualHost = ""
self.manualPortText = ""
self.manualUseTLS = true
self.manualToken = ""
self.manualPassword = ""
}
private struct SetupPayload: Codable {
var url: String?
var host: String?
var port: Int?
var tls: Bool?
var token: String?
var password: String?
}
private func applySetupCode() {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return
}
guard let payload = self.decodeSetupPayload(raw: raw) else {
self.setupStatusText = "Setup code not recognized."
return
}
if let urlString = payload.url, let url = URL(string: urlString) {
self.applyURL(url)
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
if let port = payload.port {
self.manualPortText = String(port)
} else {
self.manualPortText = ""
}
if let tls = payload.tls {
self.manualUseTLS = tls
}
} else if let url = URL(string: raw), url.scheme != nil {
self.applyURL(url)
} else {
self.setupStatusText = "Setup code missing URL or host."
return
}
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
}
self.setupStatusText = "Setup code applied."
}
private func applyURL(_ url: URL) {
guard let host = url.host, !host.isEmpty else { return }
self.manualHost = host
if let port = url.port {
self.manualPortText = String(port)
} else {
self.manualPortText = ""
}
let scheme = (url.scheme ?? "").lowercased()
if scheme == "wss" || scheme == "https" {
self.manualUseTLS = true
} else if scheme == "ws" || scheme == "http" {
self.manualUseTLS = false
}
}
private func decodeSetupPayload(raw: String) -> SetupPayload? {
if let payload = decodeSetupPayloadFromJSON(raw) {
return payload
}
if let decoded = decodeBase64Payload(raw),
let payload = decodeSetupPayloadFromJSON(decoded)
{
return payload
}
return nil
}
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(SetupPayload.self, from: data)
}
private func decodeBase64Payload(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = trimmed
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = normalized.count % 4
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
guard let data = Data(base64Encoded: padded) else { return nil }
return String(data: data, encoding: .utf8)
}
}
private struct ConnectionStatusBox: View {
let statusLines: [String]
let secondaryLine: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.statusLines, id: \.self) { line in
Text(line)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
}
if let secondaryLine, !secondaryLine.isEmpty {
Text(secondaryLine)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
static func defaultLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController
) -> [String] {
var lines: [String] = [
"gateway: \(appModel.gatewayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")
lines.append("address: \(appModel.gatewayRemoteAddress ?? "")")
return lines
}
}

View File

@@ -0,0 +1,165 @@
import EventKit
import Foundation
import OpenClawKit
final class RemindersService: RemindersServicing {
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .reminder)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Reminders", code: 1, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
])
}
let limit = max(1, min(params.limit ?? 50, 500))
let statusFilter = params.status ?? .incomplete
let predicate = store.predicateForReminders(in: nil)
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in
store.fetchReminders(matching: predicate) { items in
let formatter = ISO8601DateFormatter()
let filtered = (items ?? []).filter { reminder in
switch statusFilter {
case .all:
return true
case .completed:
return reminder.isCompleted
case .incomplete:
return !reminder.isCompleted
}
}
let selected = Array(filtered.prefix(limit))
let payload = selected.map { reminder in
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
return OpenClawReminderPayload(
identifier: reminder.calendarItemIdentifier,
title: reminder.title,
dueISO: due.map { formatter.string(from: $0) },
completed: reminder.isCompleted,
listName: reminder.calendar.title)
}
cont.resume(returning: payload)
}
}
return OpenClawRemindersListPayload(reminders: payload)
}
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .reminder)
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Reminders", code: 2, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
])
}
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw NSError(domain: "Reminders", code: 3, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required",
])
}
let reminder = EKReminder(eventStore: store)
reminder.title = title
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
reminder.notes = notes
}
reminder.calendar = try Self.resolveList(
store: store,
listId: params.listId,
listName: params.listName)
if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty {
let formatter = ISO8601DateFormatter()
guard let dueDate = formatter.date(from: dueISO) else {
throw NSError(domain: "Reminders", code: 4, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601",
])
}
reminder.dueDateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second],
from: dueDate)
}
try store.save(reminder, commit: true)
let formatter = ISO8601DateFormatter()
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
let payload = OpenClawReminderPayload(
identifier: reminder.calendarItemIdentifier,
title: reminder.title,
dueISO: due.map { formatter.string(from: $0) },
completed: reminder.isCompleted,
listName: reminder.calendar.title)
return OpenClawRemindersAddPayload(reminder: payload)
}
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
switch status {
case .authorized:
return true
case .notDetermined:
// Dont prompt during node.invoke; prompts block the invoke and lead to timeouts.
return false
case .restricted, .denied:
return false
case .fullAccess:
return true
case .writeOnly:
return false
@unknown default:
return false
}
}
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
switch status {
case .authorized, .fullAccess, .writeOnly:
return true
case .notDetermined:
// Dont prompt during node.invoke; prompts block the invoke and lead to timeouts.
return false
case .restricted, .denied:
return false
@unknown default:
return false
}
}
private static func resolveList(
store: EKEventStore,
listId: String?,
listName: String?) throws -> EKCalendar
{
if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
let calendar = store.calendar(withIdentifier: id)
{
return calendar
}
if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
if let calendar = store.calendars(for: .reminder).first(where: {
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
}) {
return calendar
}
throw NSError(domain: "Reminders", code: 5, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)",
])
}
if let fallback = store.defaultCalendarForNewReminders() {
return fallback
}
throw NSError(domain: "Reminders", code: 6, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list",
])
}
}

View File

@@ -9,9 +9,15 @@ struct RootCanvas: View {
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var didAutoOpenSettings: Bool = false
private enum PresentedSheet: Identifiable {
case settings
@@ -52,12 +58,14 @@ struct RootCanvas: View {
SettingsTab()
case .chat:
ChatSheet(
gateway: self.appModel.gatewaySession,
gateway: self.appModel.operatorSession,
sessionKey: self.appModel.mainSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
}
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.updateCanvasDebugStatus() }
@@ -65,6 +73,13 @@ struct RootCanvas: View {
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
self.hasConnectedOnce = true
}
self.maybeAutoOpenSettings()
}
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -119,12 +134,33 @@ struct RootCanvas: View {
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func shouldAutoOpenSettings() -> Bool {
if self.appModel.gatewayServerName != nil { return false }
if !self.hasConnectedOnce { return true }
if !self.onboardingComplete { return true }
return !self.hasExistingGatewayConfig()
}
private func hasExistingGatewayConfig() -> Bool {
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
return self.manualGatewayEnabled && !manualHost.isEmpty
}
private func maybeAutoOpenSettings() {
guard !self.didAutoOpenSettings else { return }
guard self.shouldAutoOpenSettings() else { return }
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
}
private struct CanvasContent: View {
@Environment(NodeAppModel.self) private var appModel
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@State private var showGatewayActions: Bool = false
var systemColorScheme: ColorScheme
var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool
@@ -182,7 +218,11 @@ private struct CanvasContent: View {
activity: self.statusActivity,
brighten: self.brightenButtons,
onTap: {
self.openSettings()
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else {
self.openSettings()
}
})
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
@@ -197,6 +237,21 @@ private struct CanvasContent: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.confirmationDialog(
"Gateway",
isPresented: self.$showGatewayActions,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
Button("Open Settings") {
self.openSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
}
private var statusActivity: StatusPill.Activity? {
@@ -248,6 +303,10 @@ private struct CanvasContent: View {
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
}
if voiceStatus == "Paused" {
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
if self.appModel.talkMode.isEnabled {
return nil
}
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
}

View File

@@ -7,6 +7,7 @@ struct RootTabs: View {
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showGatewayActions: Bool = false
var body: some View {
TabView(selection: self.$selectedTab) {
@@ -27,7 +28,13 @@ struct RootTabs: View {
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
onTap: { self.selectedTab = 2 })
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else {
self.selectedTab = 2
}
})
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
@@ -62,6 +69,21 @@ struct RootTabs: View {
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
.confirmationDialog(
"Gateway",
isPresented: self.$showGatewayActions,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
Button("Open Settings") {
self.selectedTab = 2
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
}
private var gatewayStatus: StatusPill.GatewayState {
@@ -133,6 +155,10 @@ struct RootTabs: View {
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
}
if voiceStatus == "Paused" {
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
if self.appModel.talkMode.isEnabled {
return nil
}
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
}

View File

@@ -0,0 +1,7 @@
import SwiftUI
struct RootView: View {
var body: some View {
RootCanvas()
}
}

View File

@@ -52,6 +52,20 @@ final class ScreenController {
func navigate(to urlString: String) {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.urlString = ""
self.reload()
return
}
if let url = URL(string: trimmed),
!url.isFileURL,
let host = url.host,
Self.isLoopbackHost(host)
{
// Never try to load loopback URLs from a remote gateway.
self.showDefaultCanvas()
return
}
self.urlString = (trimmed == "/" ? "" : trimmed)
self.reload()
}
@@ -239,6 +253,18 @@ final class ScreenController {
name: "scaffold",
ext: "html",
subdirectory: "CanvasScaffold")
private static func isLoopbackHost(_ host: String) -> Bool {
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return true }
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
return true
}
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
return true
}
return false
}
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
let std = url.standardizedFileURL

View File

@@ -9,7 +9,9 @@ struct ScreenTab: View {
ScreenWebView(controller: self.appModel.screen)
.ignoresSafeArea()
.overlay(alignment: .top) {
if let errorText = self.appModel.screen.errorText {
if let errorText = self.appModel.screen.errorText,
self.appModel.gatewayServerName == nil
{
Text(errorText)
.font(.footnote)
.padding(10)

View File

@@ -0,0 +1,64 @@
import CoreLocation
import Foundation
import OpenClawKit
import UIKit
protocol CameraServicing: Sendable {
func listDevices() async -> [CameraController.CameraDeviceInfo]
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int)
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool)
}
protocol ScreenRecordingServicing: Sendable {
func record(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> String
}
@MainActor
protocol LocationServicing: Sendable {
func authorizationStatus() -> CLAuthorizationStatus
func accuracyAuthorization() -> CLAccuracyAuthorization
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus
func currentLocation(
params: OpenClawLocationGetParams,
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
}
protocol DeviceStatusServicing: Sendable {
func status() async throws -> OpenClawDeviceStatusPayload
func info() -> OpenClawDeviceInfoPayload
}
protocol PhotosServicing: Sendable {
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload
}
protocol ContactsServicing: Sendable {
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload
}
protocol CalendarServicing: Sendable {
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload
}
protocol RemindersServicing: Sendable {
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload
}
protocol MotionServicing: Sendable {
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
}
extension CameraController: CameraServicing {}
extension ScreenRecordService: ScreenRecordingServicing {}
extension LocationService: LocationServicing {}

View File

@@ -0,0 +1,58 @@
import Foundation
import UserNotifications
enum NotificationAuthorizationStatus: Sendable {
case notDetermined
case denied
case authorized
case provisional
case ephemeral
}
protocol NotificationCentering: Sendable {
func authorizationStatus() async -> NotificationAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func add(_ request: UNNotificationRequest) async throws
}
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter = .current()) {
self.center = center
}
func authorizationStatus() async -> NotificationAuthorizationStatus {
let settings = await self.center.notificationSettings()
return switch settings.authorizationStatus {
case .authorized:
.authorized
case .provisional:
.provisional
case .ephemeral:
.ephemeral
case .denied:
.denied
case .notDetermined:
.notDetermined
@unknown default:
.denied
}
}
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
try await self.center.requestAuthorization(options: options)
}
func add(_ request: UNNotificationRequest) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
self.center.add(request) { error in
if let error {
cont.resume(throwing: error)
} else {
cont.resume(returning: ())
}
}
}
}
}

View File

@@ -6,6 +6,14 @@ enum SessionKey {
return trimmed.isEmpty ? "main" : trimmed
}
static func makeAgentSessionKey(agentId: String, baseKey: String) -> String {
let trimmedAgent = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedBase = baseKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedAgent.isEmpty { return trimmedBase.isEmpty ? "main" : trimmedBase }
let normalizedBase = trimmedBase.isEmpty ? "main" : trimmedBase
return "agent:\(trimmedAgent):\(normalizedBase)"
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }

View File

@@ -1,17 +1,10 @@
import OpenClawKit
import Network
import Observation
import os
import SwiftUI
import UIKit
@MainActor
@Observable
private final class ConnectStatusStore {
var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@@ -28,99 +21,140 @@ struct SettingsTab: View {
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@AppStorage("gateway.autoconnect") private var gatewayAutoConnect: Bool = false
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore()
@State private var connectingGatewayID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@AppStorage("gateway.setupCode") private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualGatewayPortText: String = ""
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
var body: some View {
NavigationStack {
Form {
Section("Node") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
LabeledContent("IP", value: self.localIPAddress ?? "")
.contextMenu {
if let ip = self.localIPAddress {
Button {
UIPasteboard.general.string = ip
} label: {
Label("Copy", systemImage: "doc.on.doc")
Section {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if !self.isGatewayConnected {
Text(
"1. Open Telegram and message your bot: /pair\n"
+ "2. Copy the setup code it returns\n"
+ "3. Paste here and tap Connect\n"
+ "4. Back in Telegram, run /pair approve")
.font(.footnote)
.foregroundStyle(.secondary)
if let warning = self.tailnetWarningText {
Text(warning)
.font(.footnote.weight(.semibold))
.foregroundStyle(.orange)
}
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect with setup code")
}
}
.disabled(self.connectingGatewayID != nil
|| self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let status = self.setupStatusLine {
Text(status)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
LabeledContent("Platform", value: self.platformString())
LabeledContent("Version", value: self.appVersion())
LabeledContent("Model", value: self.modelIdentifier())
}
Section("Gateway") {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
Text(urlString)
}
.contextMenu {
Button {
UIPasteboard.general.string = urlString
} label: {
Label("Copy URL", systemImage: "doc.on.doc")
if self.isGatewayConnected {
Picker("Bot", selection: self.$selectedAgentPickerId) {
Text("Default").tag("")
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
Text(name.isEmpty ? agent.id : name).tag(agent.id)
}
}
Text("Controls which bot Chat and Talk speak to.")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let parts {
DisclosureGroup("Advanced") {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
LabeledContent("Status", value: self.appModel.gatewayStatusText)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
Text(urlString)
}
.contextMenu {
Button {
UIPasteboard.general.string = parts.host
UIPasteboard.general.string = urlString
} label: {
Label("Copy Host", systemImage: "doc.on.doc")
Label("Copy URL", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = "\(parts.port)"
} label: {
Label("Copy Port", systemImage: "doc.on.doc")
if let parts {
Button {
UIPasteboard.general.string = parts.host
} label: {
Label("Copy Host", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = "\(parts.port)"
} label: {
Label("Copy Port", systemImage: "doc.on.doc")
}
}
}
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
} else {
self.gatewayList(showing: .all)
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
self.gatewayList(showing: .availableOnly)
} else {
self.gatewayList(showing: .all)
}
if let text = self.connectStatus.text {
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
}
DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", value: self.$manualGatewayPort, format: .number)
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
@@ -140,11 +174,11 @@ struct SettingsTab: View {
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "The gateway WebSocket listens on port 18789 by default.")
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -164,58 +198,98 @@ struct SettingsTab: View {
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
Section("Voice") {
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
.onChange(of: self.voiceWakeEnabled) { _, newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
Toggle("Talk Mode", isOn: self.$talkEnabled)
.onChange(of: self.talkEnabled) { _, newValue in
self.appModel.setTalkEnabled(newValue)
}
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
LabeledContent(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
HStack(spacing: 10) {
Circle()
.fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 10, height: 10)
Text("Gateway")
Spacer()
Text(self.gatewaySummaryText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
Section("Camera") {
Toggle("Allow Camera", isOn: self.$cameraEnabled)
Text("Allows the gateway to request photos or short video clips (foreground only).")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Device") {
DisclosureGroup("Features") {
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
.onChange(of: self.voiceWakeEnabled) { _, newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
Toggle("Talk Mode", isOn: self.$talkEnabled)
.onChange(of: self.talkEnabled) { _, newValue in
self.appModel.setTalkEnabled(newValue)
}
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
Section("Location") {
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(OpenClawLocationMode.off.rawValue)
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always").tag(OpenClawLocationMode.always.rawValue)
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
LabeledContent(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
Toggle("Allow Camera", isOn: self.$cameraEnabled)
Text("Allows the gateway to request photos or short video clips (foreground only).")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(OpenClawLocationMode.off.rawValue)
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always").tag(OpenClawLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always requires system permission and may prompt to open Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Prevent Sleep", isOn: self.$preventSleep)
Text("Keeps the screen awake while OpenClaw is open.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always requires system permission and may prompt to open Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Screen") {
Toggle("Prevent Sleep", isOn: self.$preventSleep)
Text("Keeps the screen awake while OpenClaw is open.")
.font(.footnote)
.foregroundStyle(.secondary)
DisclosureGroup("Device Info") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
LabeledContent("IP", value: self.localIPAddress ?? "")
.contextMenu {
if let ip = self.localIPAddress {
Button {
UIPasteboard.general.string = ip
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
}
LabeledContent("Platform", value: self.platformString())
LabeledContent("Version", value: self.appVersion())
LabeledContent("Model", value: self.modelIdentifier())
}
}
}
.navigationTitle("Settings")
@@ -232,11 +306,24 @@ struct SettingsTab: View {
.onAppear {
self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
self.syncManualPortText()
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
// Keep setup front-and-center when disconnected; keep things compact once connected.
self.gatewayExpanded = !self.isGatewayConnected
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
}
.onChange(of: self.selectedAgentPickerId) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
}
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
if newValue != self.selectedAgentPickerId {
self.selectedAgentPickerId = newValue
}
}
.onChange(of: self.preferredGatewayStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -255,8 +342,24 @@ struct SettingsTab: View {
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.connectStatus.text = nil
.onChange(of: self.manualGatewayPort) { _, _ in
self.syncManualPortText()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.setupCode = ""
self.setupStatusText = nil
return
}
if self.manualGatewayEnabled {
self.setupStatusText = self.appModel.gatewayStatusText
}
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.setupStatusText = trimmed
}
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
@@ -278,8 +381,24 @@ struct SettingsTab: View {
@ViewBuilder
private func gatewayList(showing: GatewayListMode) -> some View {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 12) {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
Text("If your gateway is on another network, connect it and ensure DNS is working.")
.font(.footnote)
.foregroundStyle(.secondary)
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
Button {
Task { await self.connectLastKnown() }
} label: {
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
}
.disabled(self.connectingGatewayID != nil)
.buttonStyle(.borderedProminent)
.tint(self.appModel.seamColor)
}
}
} else {
let connectedID = self.appModel.connectedGatewayID
let rows = self.gatewayController.gateways.filter { gateway in
@@ -331,6 +450,20 @@ struct SettingsTab: View {
case availableOnly
}
private var isGatewayConnected: Bool {
let status = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if status.contains("connected") { return true }
return self.appModel.gatewayServerName != nil && !status.contains("offline")
}
private var gatewaySummaryText: String {
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
return server
}
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Not connected" : trimmed
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
@@ -377,14 +510,290 @@ struct SettingsTab: View {
await self.gatewayController.connect(gateway)
}
private func connectLastKnown() async {
self.connectingGatewayID = "last-known"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectLastKnown()
}
private func gatewayDebugText() -> String {
var lines: [String] = [
"gateway: \(self.appModel.gatewayStatusText)",
"discovery: \(self.gatewayController.discoveryStatusText)",
]
lines.append("server: \(self.appModel.gatewayServerName ?? "")")
lines.append("address: \(self.appModel.gatewayRemoteAddress ?? "")")
if let last = self.gatewayController.discoveryDebugLog.last?.message {
lines.append("discovery log: \(last)")
}
return lines.joined(separator: "\n")
}
@ViewBuilder
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
if self.connectingGatewayID == "last-known" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
.frame(maxWidth: .infinity)
} else {
HStack(spacing: 8) {
Image(systemName: "bolt.horizontal.circle.fill")
VStack(alignment: .leading, spacing: 2) {
Text("Connect last known")
Text("\(host):\(port)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
}
.frame(maxWidth: .infinity)
}
}
private var manualPortBinding: Binding<String> {
Binding(
get: { self.manualGatewayPortText },
set: { newValue in
let filtered = newValue.filter(\.isNumber)
if self.manualGatewayPortText != filtered {
self.manualGatewayPortText = filtered
}
if filtered.isEmpty {
if self.manualGatewayPort != 0 {
self.manualGatewayPort = 0
}
} else if let port = Int(filtered), self.manualGatewayPort != port {
self.manualGatewayPort = port
}
})
}
private var manualPortIsValid: Bool {
if self.manualGatewayPortText.isEmpty { return true }
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
}
private func syncManualPortText() {
if self.manualGatewayPort > 0 {
let next = String(self.manualGatewayPort)
if self.manualGatewayPortText != next {
self.manualGatewayPortText = next
}
} else if !self.manualGatewayPortText.isEmpty {
self.manualGatewayPortText = ""
}
}
private struct SetupPayload: Codable {
var url: String?
var host: String?
var port: Int?
var tls: Bool?
var token: String?
var password: String?
}
private func applySetupCodeAndConnect() async {
self.setupStatusText = nil
guard self.applySetupCode() else { return }
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPort = self.resolvedManualPort(host: host)
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
GatewayDiagnostics.log(
"setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
guard let port = resolvedPort else {
self.setupStatusText = "Failed: invalid port"
return
}
let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS)
guard ok else { return }
self.setupStatusText = "Setup code applied. Connecting…"
await self.connectManual()
}
@discardableResult
private func applySetupCode() -> Bool {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return false
}
guard let payload = self.decodeSetupPayload(raw: raw) else {
self.setupStatusText = "Setup code not recognized."
return false
}
if let urlString = payload.url, let url = URL(string: urlString) {
self.applySetupURL(url)
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
if let port = payload.port {
self.manualGatewayPort = port
self.manualGatewayPortText = String(port)
} else {
self.manualGatewayPort = 0
self.manualGatewayPortText = ""
}
if let tls = payload.tls {
self.manualGatewayTLS = tls
}
} else if let url = URL(string: raw), url.scheme != nil {
self.applySetupURL(url)
} else {
self.setupStatusText = "Setup code missing URL or host."
return false
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayToken = trimmedToken
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
}
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayPassword = trimmedPassword
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
}
return true
}
private func applySetupURL(_ url: URL) {
guard let host = url.host, !host.isEmpty else { return }
self.manualGatewayHost = host
if let port = url.port {
self.manualGatewayPort = port
self.manualGatewayPortText = String(port)
} else {
self.manualGatewayPort = 0
self.manualGatewayPortText = ""
}
let scheme = (url.scheme ?? "").lowercased()
if scheme == "wss" || scheme == "https" {
self.manualGatewayTLS = true
} else if scheme == "ws" || scheme == "http" {
self.manualGatewayTLS = false
}
}
private func resolvedManualPort(host: String) -> Int? {
if self.manualGatewayPort > 0 {
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
}
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") {
return 443
}
return 18789
}
private func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() {
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
self.setupStatusText = msg
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
self.gatewayLogger.warning("\(msg, privacy: .public)")
return false
}
self.setupStatusText = "Checking gateway reachability…"
let ok = await Self.probeTCP(host: trimmed, port: port, timeoutSeconds: 3)
if !ok {
let msg = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
self.setupStatusText = msg
GatewayDiagnostics.log("preflight fail: unreachable host=\(trimmed) port=\(port)")
self.gatewayLogger.warning("\(msg, privacy: .public)")
return false
}
GatewayDiagnostics.log("preflight ok host=\(trimmed) port=\(port) tls=\(useTLS)")
return true
}
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
let endpointHost = NWEndpoint.Host(host)
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
return await withCheckedContinuation { cont in
let queue = DispatchQueue(label: "gateway.preflight")
let finished = OSAllocatedUnfairLock(initialState: false)
let finish: @Sendable (Bool) -> Void = { ok in
let shouldResume = finished.withLock { flag -> Bool in
if flag { return false }
flag = true
return true
}
guard shouldResume else { return }
connection.cancel()
cont.resume(returning: ok)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(true)
case .failed, .cancelled:
finish(false)
default:
break
}
}
connection.start(queue: queue)
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
finish(false)
}
}
}
private func decodeSetupPayload(raw: String) -> SetupPayload? {
if let payload = decodeSetupPayloadFromJSON(raw) {
return payload
}
if let decoded = decodeBase64Payload(raw),
let payload = decodeSetupPayloadFromJSON(decoded)
{
return payload
}
return nil
}
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(SetupPayload.self, from: data)
}
private func decodeBase64Payload(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = trimmed
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = normalized.count % 4
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
guard let data = Data(base64Encoded: padded) else { return nil }
return String(data: data, encoding: .utf8)
}
private func connectManual() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatus.text = "Failed: host required"
self.setupStatusText = "Failed: host required"
return
}
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
self.connectStatus.text = "Failed: invalid port"
guard self.manualPortIsValid else {
self.setupStatusText = "Failed: invalid port"
return
}
@@ -392,12 +801,54 @@ struct SettingsTab: View {
self.manualGatewayEnabled = true
defer { self.connectingGatewayID = nil }
GatewayDiagnostics.log(
"connect manual host=\(host) port=\(self.manualGatewayPort) tls=\(self.manualGatewayTLS)")
await self.gatewayController.connectManual(
host: host,
port: self.manualGatewayPort,
useTLS: self.manualGatewayTLS)
}
private var setupStatusLine: String? {
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
if !trimmedSetup.isEmpty { return trimmedSetup }
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
return gatewayStatus
}
private var tailnetWarningText: String? {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else { return nil }
guard Self.isTailnetHostOrIP(host) else { return nil }
guard !Self.hasTailnetIPv4() else { return nil }
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
}
private func friendlyGatewayMessage(from raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let lower = trimmed.lowercased()
if lower.contains("pairing required") {
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
}
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."
}
if lower.contains("device signature expired") || lower.contains("device signature invalid") {
return "Secure handshake failed. Check that your iPhone time is correct, then tap Connect again."
}
if lower.contains("connect timed out") || lower.contains("timed out") {
return "Connection timed out. Make sure Tailscale is connected, then try again."
}
if lower.contains("unauthorized role") {
return "Connected, but some controls are restricted for nodes. This is expected."
}
return nil
}
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
@@ -436,6 +887,57 @@ struct SettingsTab: View {
return en0 ?? fallback
}
private static func hasTailnetIPv4() -> Bool {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if self.isTailnetIPv4(ip) { return true }
}
return false
}
private static func isTailnetHostOrIP(_ host: String) -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") {
return true
}
return self.isTailnetIPv4(trimmed)
}
private static func isTailnetIPv4(_ ip: String) -> Bool {
let parts = ip.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
guard (0...255).contains(a), (0...255).contains(b) else { return false }
return a == 100 && b >= 64 && b <= 127
}
private static func parseHostPort(from address: String) -> SettingsHostPort? {
SettingsNetworkingHelpers.parseHostPort(from: address)
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ struct TalkOrbOverlay: View {
var body: some View {
let seam = self.appModel.seamColor
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
let mic = min(max(self.appModel.talkMode.micLevel, 0), 1)
VStack(spacing: 14) {
ZStack {
@@ -28,7 +29,7 @@ struct TalkOrbOverlay: View {
.fill(
RadialGradient(
colors: [
seam.opacity(0.95),
seam.opacity(0.75 + (0.20 * mic)),
seam.opacity(0.40),
Color.black.opacity(0.55),
],
@@ -36,6 +37,7 @@ struct TalkOrbOverlay: View {
startRadius: 1,
endRadius: 112))
.frame(width: 190, height: 190)
.scaleEffect(1.0 + (0.12 * mic))
.overlay(
Circle()
.stroke(seam.opacity(0.35), lineWidth: 1))
@@ -47,6 +49,13 @@ struct TalkOrbOverlay: View {
self.appModel.talkMode.userTappedOrb()
}
let agentName = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
if !agentName.isEmpty {
Text("Bot: \(agentName)")
.font(.system(.caption, design: .rounded).weight(.semibold))
.foregroundStyle(Color.white.opacity(0.70))
}
if !status.isEmpty, status != "Off" {
Text(status)
.font(.system(.footnote, design: .rounded).weight(.semibold))
@@ -59,6 +68,14 @@ struct TalkOrbOverlay: View {
.overlay(
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
}
if self.appModel.talkMode.isListening {
Capsule()
.fill(seam.opacity(0.90))
.frame(width: max(18, 180 * mic), height: 6)
.animation(.easeOut(duration: 0.12), value: mic)
.accessibilityLabel("Microphone level")
}
}
.padding(28)
.onAppear {

View File

@@ -1,6 +1,7 @@
import AVFAudio
import Foundation
import Observation
import OpenClawKit
import Speech
import SwabbleKit
@@ -96,6 +97,7 @@ final class VoiceWakeManager: NSObject {
private var lastDispatched: String?
private var onCommand: (@Sendable (String) async -> Void)?
private var userDefaultsObserver: NSObjectProtocol?
private var suppressedByTalk: Bool = false
override init() {
super.init()
@@ -141,9 +143,28 @@ final class VoiceWakeManager: NSObject {
}
}
func setSuppressedByTalk(_ suppressed: Bool) {
self.suppressedByTalk = suppressed
if suppressed {
_ = self.suspendForExternalAudioCapture()
if self.isEnabled {
self.statusText = "Paused"
}
} else {
if self.isEnabled {
Task { await self.start() }
}
}
}
func start() async {
guard self.isEnabled else { return }
if self.isListening { return }
guard !self.suppressedByTalk else {
self.isListening = false
self.statusText = "Paused"
return
}
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
@@ -159,14 +180,18 @@ final class VoiceWakeManager: NSObject {
let micOk = await Self.requestMicrophonePermission()
guard micOk else {
self.statusText = "Microphone permission denied"
self.statusText = Self.permissionMessage(
kind: "Microphone",
status: AVAudioSession.sharedInstance().recordPermission)
self.isListening = false
return
}
let speechOk = await Self.requestSpeechPermission()
guard speechOk else {
self.statusText = "Speech recognition permission denied"
self.statusText = Self.permissionMessage(
kind: "Speech recognition",
status: SFSpeechRecognizer.authorizationStatus())
self.isListening = false
return
}
@@ -364,20 +389,101 @@ final class VoiceWakeManager: NSObject {
}
private nonisolated static func requestMicrophonePermission() async -> Bool {
await withCheckedContinuation(isolation: nil) { cont in
AVAudioApplication.requestRecordPermission { ok in
cont.resume(returning: ok)
let session = AVAudioSession.sharedInstance()
switch session.recordPermission {
case .granted:
return true
case .denied:
return false
case .undetermined:
break
@unknown default:
return false
}
return await self.requestPermissionWithTimeout { completion in
AVAudioSession.sharedInstance().requestRecordPermission { ok in
completion(ok)
}
}
}
private nonisolated static func requestSpeechPermission() async -> Bool {
await withCheckedContinuation(isolation: nil) { cont in
SFSpeechRecognizer.requestAuthorization { status in
cont.resume(returning: status == .authorized)
let status = SFSpeechRecognizer.authorizationStatus()
switch status {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
break
@unknown default:
return false
}
return await self.requestPermissionWithTimeout { completion in
SFSpeechRecognizer.requestAuthorization { authStatus in
completion(authStatus == .authorized)
}
}
}
private nonisolated static func requestPermissionWithTimeout(
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
{
do {
return try await AsyncTimeout.withTimeout(
seconds: 8,
onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [
NSLocalizedDescriptionKey: "permission request timed out",
]) },
operation: {
await withCheckedContinuation(isolation: nil) { cont in
Task { @MainActor in
operation { ok in
cont.resume(returning: ok)
}
}
}
})
} catch {
return false
}
}
private static func permissionMessage(
kind: String,
status: AVAudioSession.RecordPermission) -> String
{
switch status {
case .denied:
return "\(kind) permission denied"
case .undetermined:
return "\(kind) permission not granted"
case .granted:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
}
private static func permissionMessage(
kind: String,
status: SFSpeechRecognizerAuthorizationStatus) -> String
{
switch status {
case .denied:
return "\(kind) permission denied"
case .restricted:
return "\(kind) permission restricted"
case .notDetermined:
return "\(kind) permission not granted"
case .authorized:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
}
}
#if DEBUG

View File

@@ -9,6 +9,7 @@ Sources/Chat/IOSGatewayChatTransport.swift
Sources/OpenClawApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift
Sources/Model/NodeAppModel+Canvas.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
Sources/Screen/ScreenController.swift

View File

@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
let account: String
}
private let gatewayService = "bot.molt.gateway"
private let nodeService = "bot.molt.node"
private let gatewayService = "ai.openclaw.gateway"
private let nodeService = "ai.openclaw.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")

View File

@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.4</string>
<string>2026.2.6</string>
<key>CFBundleVersion</key>
<string>20260202</string>
</dict>

View File

@@ -101,7 +101,8 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(presentRes.ok == true)
#expect(appModel.screen.urlString.isEmpty)
let navigateParams = OpenClawCanvasNavigateParams(url: "http://localhost:18789/")
// Loopback URLs are rejected (they are not meaningful for a remote gateway).
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
let navData = try JSONEncoder().encode(navigateParams)
let navJSON = String(decoding: navData, as: UTF8.self)
let navigate = BridgeInvokeRequest(
@@ -110,7 +111,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
paramsJSON: navJSON)
let navRes = await appModel._test_handleInvoke(navigate)
#expect(navRes.ok == true)
#expect(appModel.screen.urlString == "http://localhost:18789/")
#expect(appModel.screen.urlString == "http://example.com/")
let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1")
let evalData = try JSONEncoder().encode(evalParams)

View File

@@ -81,7 +81,7 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.2.4"
CFBundleShortVersionString: "2026.2.6"
CFBundleVersion: "20260202"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.4"
CFBundleShortVersionString: "2026.2.6"
CFBundleVersion: "20260202"

View File

@@ -29,7 +29,7 @@ struct CronJobEditor: View {
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
@State var wakeMode: CronWakeMode = .now
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@@ -119,8 +119,8 @@ struct CronJobEditor: View {
GridRow {
self.gridLabel("Wake mode")
Picker("", selection: self.$wakeMode) {
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
Text("now").tag(CronWakeMode.now)
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
}
.labelsHidden()
.pickerStyle(.segmented)

View File

@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.4</string>
<string>2026.2.6</string>
<key>CFBundleVersion</key>
<string>202602020</string>
<key>CFBundleIconFile</key>

View File

@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
}
}
public struct SessionsUsageParams: Codable, Sendable {
public let key: String?
public let startdate: String?
public let enddate: String?
public let limit: Int?
public let includecontextweight: Bool?
public init(
key: String?,
startdate: String?,
enddate: String?,
limit: Int?,
includecontextweight: Bool?
) {
self.key = key
self.startdate = startdate
self.enddate = enddate
self.limit = limit
self.includecontextweight = includecontextweight
}
private enum CodingKeys: String, CodingKey {
case key
case startdate = "startDate"
case enddate = "endDate"
case limit
case includecontextweight = "includeContextWeight"
}
}
public struct ConfigGetParams: Codable, Sendable {
}
@@ -1560,6 +1589,140 @@ public struct AgentSummary: Codable, Sendable {
}
}
public struct AgentsCreateParams: Codable, Sendable {
public let name: String
public let workspace: String
public let emoji: String?
public let avatar: String?
public init(
name: String,
workspace: String,
emoji: String?,
avatar: String?
) {
self.name = name
self.workspace = workspace
self.emoji = emoji
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case name
case workspace
case emoji
case avatar
}
}
public struct AgentsCreateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let name: String
public let workspace: String
public init(
ok: Bool,
agentid: String,
name: String,
workspace: String
) {
self.ok = ok
self.agentid = agentid
self.name = name
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case name
case workspace
}
}
public struct AgentsUpdateParams: Codable, Sendable {
public let agentid: String
public let name: String?
public let workspace: String?
public let model: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
workspace: String?,
model: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.workspace = workspace
self.model = model
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case workspace
case model
case avatar
}
}
public struct AgentsUpdateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public init(
ok: Bool,
agentid: String
) {
self.ok = ok
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
}
}
public struct AgentsDeleteParams: Codable, Sendable {
public let agentid: String
public let deletefiles: Bool?
public init(
agentid: String,
deletefiles: Bool?
) {
self.agentid = agentid
self.deletefiles = deletefiles
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case deletefiles = "deleteFiles"
}
}
public struct AgentsDeleteResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let removedbindings: Int
public init(
ok: Bool,
agentid: String,
removedbindings: Int
) {
self.ok = ok
self.agentid = agentid
self.removedbindings = removedbindings
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case removedbindings = "removedBindings"
}
}
public struct AgentsFileEntry: Codable, Sendable {
public let name: String
public let path: String
@@ -1996,6 +2159,8 @@ public struct CronRunLogEntry: Codable, Sendable {
public let status: AnyCodable?
public let error: String?
public let summary: String?
public let sessionid: String?
public let sessionkey: String?
public let runatms: Int?
public let durationms: Int?
public let nextrunatms: Int?
@@ -2007,6 +2172,8 @@ public struct CronRunLogEntry: Codable, Sendable {
status: AnyCodable?,
error: String?,
summary: String?,
sessionid: String?,
sessionkey: String?,
runatms: Int?,
durationms: Int?,
nextrunatms: Int?
@@ -2017,6 +2184,8 @@ public struct CronRunLogEntry: Codable, Sendable {
self.status = status
self.error = error
self.summary = summary
self.sessionid = sessionid
self.sessionkey = sessionkey
self.runatms = runatms
self.durationms = durationms
self.nextrunatms = nextrunatms
@@ -2028,6 +2197,8 @@ public struct CronRunLogEntry: Codable, Sendable {
case status
case error
case summary
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case runatms = "runAtMs"
case durationms = "durationMs"
case nextrunatms = "nextRunAtMs"

View File

@@ -0,0 +1,93 @@
import Foundation
public enum OpenClawCalendarCommand: String, Codable, Sendable {
case events = "calendar.events"
case add = "calendar.add"
}
public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable {
public var startISO: String?
public var endISO: String?
public var limit: Int?
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
self.startISO = startISO
self.endISO = endISO
self.limit = limit
}
}
public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable {
public var title: String
public var startISO: String
public var endISO: String
public var isAllDay: Bool?
public var location: String?
public var notes: String?
public var calendarId: String?
public var calendarTitle: String?
public init(
title: String,
startISO: String,
endISO: String,
isAllDay: Bool? = nil,
location: String? = nil,
notes: String? = nil,
calendarId: String? = nil,
calendarTitle: String? = nil)
{
self.title = title
self.startISO = startISO
self.endISO = endISO
self.isAllDay = isAllDay
self.location = location
self.notes = notes
self.calendarId = calendarId
self.calendarTitle = calendarTitle
}
}
public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable {
public var identifier: String
public var title: String
public var startISO: String
public var endISO: String
public var isAllDay: Bool
public var location: String?
public var calendarTitle: String?
public init(
identifier: String,
title: String,
startISO: String,
endISO: String,
isAllDay: Bool,
location: String? = nil,
calendarTitle: String? = nil)
{
self.identifier = identifier
self.title = title
self.startISO = startISO
self.endISO = endISO
self.isAllDay = isAllDay
self.location = location
self.calendarTitle = calendarTitle
}
}
public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable {
public var events: [OpenClawCalendarEventPayload]
public init(events: [OpenClawCalendarEventPayload]) {
self.events = events
}
}
public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable {
public var event: OpenClawCalendarEventPayload
public init(event: OpenClawCalendarEventPayload) {
self.event = event
}
}

View File

@@ -6,4 +6,10 @@ public enum OpenClawCapability: String, Codable, Sendable {
case screen
case voiceWake
case location
case device
case photos
case contacts
case calendar
case reminders
case motion
}

View File

@@ -0,0 +1,23 @@
import Foundation
public enum OpenClawChatCommand: String, Codable, Sendable {
case push = "chat.push"
}
public struct OpenClawChatPushParams: Codable, Sendable, Equatable {
public var text: String
public var speak: Bool?
public init(text: String, speak: Bool? = nil) {
self.text = text
self.speak = speak
}
}
public struct OpenClawChatPushPayload: Codable, Sendable, Equatable {
public var messageId: String?
public init(messageId: String? = nil) {
self.messageId = messageId
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
public enum OpenClawContactsCommand: String, Codable, Sendable {
case search = "contacts.search"
case add = "contacts.add"
}
public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable {
public var query: String?
public var limit: Int?
public init(query: String? = nil, limit: Int? = nil) {
self.query = query
self.limit = limit
}
}
public struct OpenClawContactsAddParams: Codable, Sendable, Equatable {
public var givenName: String?
public var familyName: String?
public var organizationName: String?
public var displayName: String?
public var phoneNumbers: [String]?
public var emails: [String]?
public init(
givenName: String? = nil,
familyName: String? = nil,
organizationName: String? = nil,
displayName: String? = nil,
phoneNumbers: [String]? = nil,
emails: [String]? = nil)
{
self.givenName = givenName
self.familyName = familyName
self.organizationName = organizationName
self.displayName = displayName
self.phoneNumbers = phoneNumbers
self.emails = emails
}
}
public struct OpenClawContactPayload: Codable, Sendable, Equatable {
public var identifier: String
public var displayName: String
public var givenName: String
public var familyName: String
public var organizationName: String
public var phoneNumbers: [String]
public var emails: [String]
public init(
identifier: String,
displayName: String,
givenName: String,
familyName: String,
organizationName: String,
phoneNumbers: [String],
emails: [String])
{
self.identifier = identifier
self.displayName = displayName
self.givenName = givenName
self.familyName = familyName
self.organizationName = organizationName
self.phoneNumbers = phoneNumbers
self.emails = emails
}
}
public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable {
public var contacts: [OpenClawContactPayload]
public init(contacts: [OpenClawContactPayload]) {
self.contacts = contacts
}
}
public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable {
public var contact: OpenClawContactPayload
public init(contact: OpenClawContactPayload) {
self.contact = contact
}
}

View File

@@ -0,0 +1,134 @@
import Foundation
public enum OpenClawDeviceCommand: String, Codable, Sendable {
case status = "device.status"
case info = "device.info"
}
public enum OpenClawBatteryState: String, Codable, Sendable {
case unknown
case unplugged
case charging
case full
}
public enum OpenClawThermalState: String, Codable, Sendable {
case nominal
case fair
case serious
case critical
}
public enum OpenClawNetworkPathStatus: String, Codable, Sendable {
case satisfied
case unsatisfied
case requiresConnection
}
public enum OpenClawNetworkInterfaceType: String, Codable, Sendable {
case wifi
case cellular
case wired
case other
}
public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable {
public var level: Double?
public var state: OpenClawBatteryState
public var lowPowerModeEnabled: Bool
public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) {
self.level = level
self.state = state
self.lowPowerModeEnabled = lowPowerModeEnabled
}
}
public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable {
public var state: OpenClawThermalState
public init(state: OpenClawThermalState) {
self.state = state
}
}
public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable {
public var totalBytes: Int64
public var freeBytes: Int64
public var usedBytes: Int64
public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) {
self.totalBytes = totalBytes
self.freeBytes = freeBytes
self.usedBytes = usedBytes
}
}
public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable {
public var status: OpenClawNetworkPathStatus
public var isExpensive: Bool
public var isConstrained: Bool
public var interfaces: [OpenClawNetworkInterfaceType]
public init(
status: OpenClawNetworkPathStatus,
isExpensive: Bool,
isConstrained: Bool,
interfaces: [OpenClawNetworkInterfaceType])
{
self.status = status
self.isExpensive = isExpensive
self.isConstrained = isConstrained
self.interfaces = interfaces
}
}
public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable {
public var battery: OpenClawBatteryStatusPayload
public var thermal: OpenClawThermalStatusPayload
public var storage: OpenClawStorageStatusPayload
public var network: OpenClawNetworkStatusPayload
public var uptimeSeconds: Double
public init(
battery: OpenClawBatteryStatusPayload,
thermal: OpenClawThermalStatusPayload,
storage: OpenClawStorageStatusPayload,
network: OpenClawNetworkStatusPayload,
uptimeSeconds: Double)
{
self.battery = battery
self.thermal = thermal
self.storage = storage
self.network = network
self.uptimeSeconds = uptimeSeconds
}
}
public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable {
public var deviceName: String
public var modelIdentifier: String
public var systemName: String
public var systemVersion: String
public var appVersion: String
public var appBuild: String
public var locale: String
public init(
deviceName: String,
modelIdentifier: String,
systemName: String,
systemVersion: String,
appVersion: String,
appBuild: String,
locale: String)
{
self.deviceName = deviceName
self.modelIdentifier = modelIdentifier
self.systemName = systemName
self.systemVersion = systemVersion
self.appVersion = appVersion
self.appBuild = appBuild
self.locale = locale
}
}

View File

@@ -72,6 +72,10 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
// When false, the connection omits the signed device identity payload.
// This is useful for secondary "operator" connections where the shared gateway token
// should authorize without triggering device pairing flows.
public var includeDeviceIdentity: Bool
public init(
role: String,
@@ -81,7 +85,8 @@ public struct GatewayConnectOptions: Sendable {
permissions: [String: Bool],
clientId: String,
clientMode: String,
clientDisplayName: String?)
clientDisplayName: String?,
includeDeviceIdentity: Bool = true)
{
self.role = role
self.scopes = scopes
@@ -91,6 +96,7 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -128,7 +134,7 @@ public actor GatewayChannelActor {
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 0.75
private let connectChallengeTimeoutSeconds: Double = 3.0
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
@@ -307,9 +313,15 @@ public actor GatewayChannelActor {
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
let identity = DeviceIdentityStore.loadOrCreate()
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
let authToken = storedToken ?? self.token
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let storedToken =
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil
// If we're not sending a device identity, a device token can't be validated server-side.
// In that mode we always use the shared gateway token/password.
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
let authSource: GatewayAuthSource
if storedToken != nil {
authSource = .deviceToken
@@ -322,7 +334,7 @@ public actor GatewayChannelActor {
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = storedToken != nil && self.token != nil
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
} else if let password = self.password {
@@ -333,7 +345,7 @@ public actor GatewayChannelActor {
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
identity?.deviceId ?? "",
clientId,
clientMode,
role,
@@ -345,18 +357,20 @@ public actor GatewayChannelActor {
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)
if includeDeviceIdentity, let identity {
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)
}
params["device"] = ProtoAnyCodable(device)
}
let frame = RequestFrame(
@@ -371,7 +385,9 @@ public actor GatewayChannelActor {
try await self.handleConnectResponse(response, identity: identity, role: role)
} catch {
if canFallbackToShared {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
if let identity {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
}
throw error
}
@@ -379,7 +395,7 @@ public actor GatewayChannelActor {
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity,
identity: DeviceIdentity?,
role: String
) async throws {
if res.ok == false {
@@ -404,11 +420,13 @@ public actor GatewayChannelActor {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
if let identity {
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
}
self.lastTick = Date()
self.tickTask?.cancel()
@@ -498,7 +516,10 @@ public actor GatewayChannelActor {
}
})
} catch {
if error is ConnectChallengeError { return nil }
if error is ConnectChallengeError {
self.logger.warning("gateway connect challenge timed out")
return nil
}
throw error
}
}

View File

@@ -21,6 +21,7 @@ public actor GatewayNodeSession {
private var activeURL: URL?
private var activeToken: String?
private var activePassword: String?
private var activeConnectOptionsKey: String?
private var connectOptions: GatewayConnectOptions?
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
@@ -103,6 +104,42 @@ public actor GatewayNodeSession {
public init() {}
private func connectOptionsKey(_ options: GatewayConnectOptions) -> String {
func sorted(_ values: [String]) -> String {
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.sorted()
.joined(separator: ",")
}
let role = options.role.trimmingCharacters(in: .whitespacesAndNewlines)
let scopes = sorted(options.scopes)
let caps = sorted(options.caps)
let commands = sorted(options.commands)
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
return "\(trimmed)=\(value ? "1" : "0")"
}
.sorted()
.joined(separator: ",")
return [
role,
scopes,
caps,
commands,
clientId,
clientMode,
clientDisplayName,
includeDeviceIdentity,
permissions,
].joined(separator: "|")
}
public func connect(
url: URL,
token: String?,
@@ -113,9 +150,11 @@ public actor GatewayNodeSession {
onDisconnected: @escaping @Sendable (String) async -> Void,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async throws {
let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
self.activePassword != password ||
self.activeConnectOptionsKey != nextOptionsKey ||
self.channel == nil
self.connectOptions = connectOptions
@@ -138,12 +177,13 @@ public actor GatewayNodeSession {
},
connectOptions: connectOptions,
disconnectHandler: { [weak self] reason in
await self?.onDisconnected?(reason)
await self?.handleChannelDisconnected(reason)
})
self.channel = channel
self.activeURL = url
self.activeToken = token
self.activePassword = password
self.activeConnectOptionsKey = nextOptionsKey
}
guard let channel = self.channel else {
@@ -157,7 +197,6 @@ public actor GatewayNodeSession {
_ = await self.waitForSnapshot(timeoutMs: 500)
await self.notifyConnectedIfNeeded()
} catch {
await onDisconnected(error.localizedDescription)
throw error
}
}
@@ -168,6 +207,7 @@ public actor GatewayNodeSession {
self.activeURL = nil
self.activeToken = nil
self.activePassword = nil
self.activeConnectOptionsKey = nil
self.resetConnectionState()
}
@@ -249,6 +289,13 @@ public actor GatewayNodeSession {
}
}
private func handleChannelDisconnected(_ reason: String) async {
// The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh
// onConnected callback once a new snapshot arrives after reconnect.
self.resetConnectionState()
await self.onDisconnected?(reason)
}
private func markSnapshotReceived() {
self.snapshotReceived = true
if !self.snapshotWaiters.isEmpty {

View File

@@ -0,0 +1,95 @@
import Foundation
public enum OpenClawMotionCommand: String, Codable, Sendable {
case activity = "motion.activity"
case pedometer = "motion.pedometer"
}
public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable {
public var startISO: String?
public var endISO: String?
public var limit: Int?
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
self.startISO = startISO
self.endISO = endISO
self.limit = limit
}
}
public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable {
public var startISO: String
public var endISO: String
public var confidence: String
public var isWalking: Bool
public var isRunning: Bool
public var isCycling: Bool
public var isAutomotive: Bool
public var isStationary: Bool
public var isUnknown: Bool
public init(
startISO: String,
endISO: String,
confidence: String,
isWalking: Bool,
isRunning: Bool,
isCycling: Bool,
isAutomotive: Bool,
isStationary: Bool,
isUnknown: Bool)
{
self.startISO = startISO
self.endISO = endISO
self.confidence = confidence
self.isWalking = isWalking
self.isRunning = isRunning
self.isCycling = isCycling
self.isAutomotive = isAutomotive
self.isStationary = isStationary
self.isUnknown = isUnknown
}
}
public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable {
public var activities: [OpenClawMotionActivityEntry]
public init(activities: [OpenClawMotionActivityEntry]) {
self.activities = activities
}
}
public struct OpenClawPedometerParams: Codable, Sendable, Equatable {
public var startISO: String?
public var endISO: String?
public init(startISO: String? = nil, endISO: String? = nil) {
self.startISO = startISO
self.endISO = endISO
}
}
public struct OpenClawPedometerPayload: Codable, Sendable, Equatable {
public var startISO: String
public var endISO: String
public var steps: Int?
public var distanceMeters: Double?
public var floorsAscended: Int?
public var floorsDescended: Int?
public init(
startISO: String,
endISO: String,
steps: Int?,
distanceMeters: Double?,
floorsAscended: Int?,
floorsDescended: Int?)
{
self.startISO = startISO
self.endISO = endISO
self.steps = steps
self.distanceMeters = distanceMeters
self.floorsAscended = floorsAscended
self.floorsDescended = floorsDescended
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
public enum OpenClawPhotosCommand: String, Codable, Sendable {
case latest = "photos.latest"
}
public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable {
public var limit: Int?
public var maxWidth: Int?
public var quality: Double?
public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) {
self.limit = limit
self.maxWidth = maxWidth
self.quality = quality
}
}
public struct OpenClawPhotoPayload: Codable, Sendable, Equatable {
public var format: String
public var base64: String
public var width: Int
public var height: Int
public var createdAt: String?
public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) {
self.format = format
self.base64 = base64
self.width = width
self.height = height
self.createdAt = createdAt
}
}
public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable {
public var photos: [OpenClawPhotoPayload]
public init(photos: [OpenClawPhotoPayload]) {
self.photos = photos
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
public enum OpenClawRemindersCommand: String, Codable, Sendable {
case list = "reminders.list"
case add = "reminders.add"
}
public enum OpenClawReminderStatusFilter: String, Codable, Sendable {
case incomplete
case completed
case all
}
public struct OpenClawRemindersListParams: Codable, Sendable, Equatable {
public var status: OpenClawReminderStatusFilter?
public var limit: Int?
public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) {
self.status = status
self.limit = limit
}
}
public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable {
public var title: String
public var dueISO: String?
public var notes: String?
public var listId: String?
public var listName: String?
public init(
title: String,
dueISO: String? = nil,
notes: String? = nil,
listId: String? = nil,
listName: String? = nil)
{
self.title = title
self.dueISO = dueISO
self.notes = notes
self.listId = listId
self.listName = listName
}
}
public struct OpenClawReminderPayload: Codable, Sendable, Equatable {
public var identifier: String
public var title: String
public var dueISO: String?
public var completed: Bool
public var listName: String?
public init(
identifier: String,
title: String,
dueISO: String? = nil,
completed: Bool,
listName: String? = nil)
{
self.identifier = identifier
self.title = title
self.dueISO = dueISO
self.completed = completed
self.listName = listName
}
}
public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable {
public var reminders: [OpenClawReminderPayload]
public init(reminders: [OpenClawReminderPayload]) {
self.reminders = reminders
}
}
public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable {
public var reminder: OpenClawReminderPayload
public init(reminder: OpenClawReminderPayload) {
self.reminder = reminder
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
public enum OpenClawTalkCommand: String, Codable, Sendable {
case pttStart = "talk.ptt.start"
case pttStop = "talk.ptt.stop"
case pttCancel = "talk.ptt.cancel"
case pttOnce = "talk.ptt.once"
}
public struct OpenClawTalkPTTStartPayload: Codable, Sendable, Equatable {
public var captureId: String
public init(captureId: String) {
self.captureId = captureId
}
}
public struct OpenClawTalkPTTStopPayload: Codable, Sendable, Equatable {
public var captureId: String
public var transcript: String?
public var status: String
public init(captureId: String, transcript: String?, status: String) {
self.captureId = captureId
self.transcript = transcript
self.status = status
}
}

View File

@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
}
}
public struct SessionsUsageParams: Codable, Sendable {
public let key: String?
public let startdate: String?
public let enddate: String?
public let limit: Int?
public let includecontextweight: Bool?
public init(
key: String?,
startdate: String?,
enddate: String?,
limit: Int?,
includecontextweight: Bool?
) {
self.key = key
self.startdate = startdate
self.enddate = enddate
self.limit = limit
self.includecontextweight = includecontextweight
}
private enum CodingKeys: String, CodingKey {
case key
case startdate = "startDate"
case enddate = "endDate"
case limit
case includecontextweight = "includeContextWeight"
}
}
public struct ConfigGetParams: Codable, Sendable {
}
@@ -1560,6 +1589,140 @@ public struct AgentSummary: Codable, Sendable {
}
}
public struct AgentsCreateParams: Codable, Sendable {
public let name: String
public let workspace: String
public let emoji: String?
public let avatar: String?
public init(
name: String,
workspace: String,
emoji: String?,
avatar: String?
) {
self.name = name
self.workspace = workspace
self.emoji = emoji
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case name
case workspace
case emoji
case avatar
}
}
public struct AgentsCreateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let name: String
public let workspace: String
public init(
ok: Bool,
agentid: String,
name: String,
workspace: String
) {
self.ok = ok
self.agentid = agentid
self.name = name
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case name
case workspace
}
}
public struct AgentsUpdateParams: Codable, Sendable {
public let agentid: String
public let name: String?
public let workspace: String?
public let model: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
workspace: String?,
model: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.workspace = workspace
self.model = model
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case workspace
case model
case avatar
}
}
public struct AgentsUpdateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public init(
ok: Bool,
agentid: String
) {
self.ok = ok
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
}
}
public struct AgentsDeleteParams: Codable, Sendable {
public let agentid: String
public let deletefiles: Bool?
public init(
agentid: String,
deletefiles: Bool?
) {
self.agentid = agentid
self.deletefiles = deletefiles
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case deletefiles = "deleteFiles"
}
}
public struct AgentsDeleteResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let removedbindings: Int
public init(
ok: Bool,
agentid: String,
removedbindings: Int
) {
self.ok = ok
self.agentid = agentid
self.removedbindings = removedbindings
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case removedbindings = "removedBindings"
}
}
public struct AgentsFileEntry: Codable, Sendable {
public let name: String
public let path: String
@@ -1996,6 +2159,8 @@ public struct CronRunLogEntry: Codable, Sendable {
public let status: AnyCodable?
public let error: String?
public let summary: String?
public let sessionid: String?
public let sessionkey: String?
public let runatms: Int?
public let durationms: Int?
public let nextrunatms: Int?
@@ -2007,6 +2172,8 @@ public struct CronRunLogEntry: Codable, Sendable {
status: AnyCodable?,
error: String?,
summary: String?,
sessionid: String?,
sessionkey: String?,
runatms: Int?,
durationms: Int?,
nextrunatms: Int?
@@ -2017,6 +2184,8 @@ public struct CronRunLogEntry: Codable, Sendable {
self.status = status
self.error = error
self.summary = summary
self.sessionid = sessionid
self.sessionkey = sessionkey
self.runatms = runatms
self.durationms = durationms
self.nextrunatms = nextrunatms
@@ -2028,6 +2197,8 @@ public struct CronRunLogEntry: Codable, Sendable {
case status
case error
case summary
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case runatms = "runAtMs"
case durationms = "durationMs"
case nextrunatms = "nextRunAtMs"

View File

@@ -0,0 +1,14 @@
[
{ "source": "OpenClaw", "target": "OpenClaw" },
{ "source": "Gateway", "target": "Gateway" },
{ "source": "Pi", "target": "Pi" },
{ "source": "Skills", "target": "Skills" },
{ "source": "local loopback", "target": "local loopback" },
{ "source": "Tailscale", "target": "Tailscale" },
{ "source": "Getting Started", "target": "はじめに" },
{ "source": "Getting started", "target": "はじめに" },
{ "source": "Quick start", "target": "クイックスタート" },
{ "source": "Quick Start", "target": "クイックスタート" },
{ "source": "Onboarding", "target": "オンボーディング" },
{ "source": "wizard", "target": "ウィザード" }
]

View File

View File

@@ -17,6 +17,8 @@ the right time, and can optionally deliver output back to a chat.
If you want _“run this every morning”_ or _“poke the agent in 20 minutes”_,
cron is the mechanism.
Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
## TL;DR
- Cron runs **inside the Gateway** (not inside the model).
@@ -40,7 +42,7 @@ openclaw cron add \
--delete-after-run
openclaw cron list
openclaw cron run <job-id> --force
openclaw cron run <job-id>
openclaw cron runs --id <job-id>
```
@@ -123,8 +125,8 @@ local timezone is used.
Main jobs enqueue a system event and optionally wake the heartbeat runner.
They must use `payload.kind = "systemEvent"`.
- `wakeMode: "next-heartbeat"` (default): event waits for the next scheduled heartbeat.
- `wakeMode: "now"`: event triggers an immediate heartbeat run.
- `wakeMode: "now"` (default): event triggers an immediate heartbeat run.
- `wakeMode: "next-heartbeat"`: event waits for the next scheduled heartbeat.
This is the best fit when you want the normal heartbeat prompt + main-session context.
See [Heartbeat](/gateway/heartbeat).
@@ -288,7 +290,7 @@ Notes:
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`.
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
- `wakeMode` defaults to `"now"` when omitted.
### cron.update params
@@ -420,10 +422,11 @@ openclaw cron edit <jobId> --agent ops
openclaw cron edit <jobId> --clear-agent
```
Manual run (debug):
Manual run (force is the default, use `--due` to only run when due):
```bash
openclaw cron run <jobId> --force
openclaw cron run <jobId>
openclaw cron run <jobId> --due
```
Edit an existing job (patch fields):
@@ -461,6 +464,13 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
- Check the Gateway is running continuously (cron runs inside the Gateway process).
- For `cron` schedules: confirm timezone (`--tz`) vs the host timezone.
### A recurring job keeps delaying after failures
- OpenClaw applies exponential retry backoff for recurring jobs after consecutive errors:
30s, 1m, 5m, 15m, then 60m between retries.
- Backoff resets automatically after the next successful run.
- One-shot (`at`) jobs disable after a terminal run (`ok`, `error`, or `skipped`) and do not retry.
### Telegram delivers to the wrong place
- For forum topics, use `-100…:topic:<id>` so its explicit and unambiguous.

View File

@@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds:
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks).
Common uses:
@@ -444,7 +444,7 @@ openclaw hooks enable session-memory
openclaw hooks disable command-logger
```
## Bundled Hooks
## Bundled hook reference
### session-memory
@@ -787,6 +787,7 @@ Session reset
```
3. List all discovered hooks:
```bash
openclaw hooks list
```
@@ -818,6 +819,7 @@ Look for missing:
2. Restart your gateway process so hooks reload.
3. Check gateway logs for errors:
```bash
./scripts/clawlog.sh | grep hook
```
@@ -892,6 +894,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
```
4. Verify and restart your gateway process:
```bash
openclaw hooks list
# Should show: 🎯 my-hook ✓

View File

@@ -0,0 +1,122 @@
---
summary: "Troubleshoot cron and heartbeat scheduling and delivery"
read_when:
- Cron did not run
- Cron ran but no message was delivered
- Heartbeat seems silent or skipped
title: "Automation Troubleshooting"
---
# Automation troubleshooting
Use this page for scheduler and delivery issues (`cron` + `heartbeat`).
## Command ladder
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then run automation checks:
```bash
openclaw cron status
openclaw cron list
openclaw system heartbeat last
```
## Cron not firing
```bash
openclaw cron status
openclaw cron list
openclaw cron runs --id <jobId> --limit 20
openclaw logs --follow
```
Good output looks like:
- `cron status` reports enabled and a future `nextWakeAtMs`.
- Job is enabled and has a valid schedule/timezone.
- `cron runs` shows `ok` or explicit skip reason.
Common signatures:
- `cron: scheduler disabled; jobs will not run automatically` → cron disabled in config/env.
- `cron: timer tick failed` → scheduler tick crashed; inspect surrounding stack/log context.
- `reason: not-due` in run output → manual run called without `--force` and job not due yet.
## Cron fired but no delivery
```bash
openclaw cron runs --id <jobId> --limit 20
openclaw cron list
openclaw channels status --probe
openclaw logs --follow
```
Good output looks like:
- Run status is `ok`.
- Delivery mode/target are set for isolated jobs.
- Channel probe reports target channel connected.
Common signatures:
- Run succeeded but delivery mode is `none` → no external message is expected.
- Delivery target missing/invalid (`channel`/`to`) → run may succeed internally but skip outbound.
- Channel auth errors (`unauthorized`, `missing_scope`, `Forbidden`) → delivery blocked by channel credentials/permissions.
## Heartbeat suppressed or skipped
```bash
openclaw system heartbeat last
openclaw logs --follow
openclaw config get agents.defaults.heartbeat
openclaw channels status --probe
```
Good output looks like:
- Heartbeat enabled with non-zero interval.
- Last heartbeat result is `ran` (or skip reason is understood).
Common signatures:
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
- `requests-in-flight` → main lane busy; heartbeat deferred.
- `empty-heartbeat-file``HEARTBEAT.md` exists but has no actionable content.
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
## Timezone and activeHours gotchas
```bash
openclaw config get agents.defaults.heartbeat.activeHours
openclaw config get agents.defaults.heartbeat.activeHours.timezone
openclaw config get agents.defaults.userTimezone || echo "agents.defaults.userTimezone not set"
openclaw cron list
openclaw logs --follow
```
Quick rules:
- `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set).
- Cron without `--tz` uses gateway host timezone.
- Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz).
- ISO timestamps without timezone are treated as UTC for cron `at` schedules.
Common signatures:
- Jobs run at the wrong wall-clock time after host timezone changes.
- Heartbeat always skipped during your daytime because `activeHours.timezone` is wrong.
Related:
- [/automation/cron-jobs](/automation/cron-jobs)
- [/gateway/heartbeat](/gateway/heartbeat)
- [/automation/cron-vs-heartbeat](/automation/cron-vs-heartbeat)
- [/concepts/timezone](/concepts/timezone)

View File

@@ -12,7 +12,7 @@ OpenClaw uses Brave Search as the default provider for `web_search`.
## Get an API key
1. Create a Brave Search API account at https://brave.com/search/api/
1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/)
2. In the dashboard, choose the **Data for Search** plan and generate an API key.
3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.

View File

@@ -18,7 +18,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
- OpenClaw talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Pairing/allowlist works the same way as other channels (`/channels/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying.
- Advanced features: edit, unsend, reply threading, message effects, group management.
@@ -27,6 +27,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)).
2. In the BlueBubbles config, enable the web API and set a password.
3. Run `openclaw onboard` and select BlueBubbles, or configure manually:
```json5
{
channels: {
@@ -39,6 +40,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
},
}
```
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing.
@@ -147,7 +149,7 @@ DMs:
- Approve via:
- `openclaw pairing list bluebubbles`
- `openclaw pairing approve bluebubbles <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
- Pairing is the default token exchange. Details: [Pairing](/channels/pairing)
Groups:
@@ -335,4 +337,4 @@ Prefer `chat_guid` for stable routing:
- OpenClaw auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
- For status/health info: `openclaw status --all` or `openclaw status --deep`.
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide.
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/tools/plugin) guide.

View File

@@ -437,6 +437,6 @@ Planned features:
## See Also
- [Multi-Agent Configuration](/multi-agent-sandbox-tools)
- [Routing Configuration](/concepts/channel-routing)
- [Multi-Agent Configuration](/tools/multi-agent-sandbox-tools)
- [Routing Configuration](/channels/channel-routing)
- [Session Management](/concepts/sessions)

View File

@@ -68,7 +68,7 @@ Config:
}
```
See: [Broadcast Groups](/broadcast-groups).
See: [Broadcast Groups](/channels/broadcast-groups).
## Config overview

View File

@@ -75,7 +75,7 @@ Choose **Feishu**, then enter the App ID and App Secret.
Visit [Feishu Open Platform](https://open.feishu.cn/app) and sign in.
Lark (global) tenants should use https://open.larksuite.com/app and set `domain: "lark"` in the Feishu config.
Lark (global) tenants should use [https://open.larksuite.com/app](https://open.larksuite.com/app) and set `domain: "lark"` in the Feishu config.
### 2. Create an app
@@ -261,10 +261,12 @@ After approval, you can chat normally.
- **Default**: `dmPolicy: "pairing"` (unknown users get a pairing code)
- **Approve pairing**:
```bash
openclaw pairing list feishu
openclaw pairing approve feishu <CODE>
```
- **Allowlist mode**: set `channels.feishu.allowFrom` with allowed Open IDs
### Group chats
@@ -488,14 +490,14 @@ Use `bindings` to route Feishu DMs or groups to different agents.
agentId: "main",
match: {
channel: "feishu",
peer: { kind: "dm", id: "ou_xxx" },
peer: { kind: "direct", id: "ou_xxx" },
},
},
{
agentId: "clawd-fan",
match: {
channel: "feishu",
peer: { kind: "dm", id: "ou_yyy" },
peer: { kind: "direct", id: "ou_yyy" },
},
},
{
@@ -512,7 +514,7 @@ Use `bindings` to route Feishu DMs or groups to different agents.
Routing fields:
- `match.channel`: `"feishu"`
- `match.peer.kind`: `"dm"` or `"group"`
- `match.peer.kind`: `"direct"` or `"group"`
- `match.peer.id`: user Open ID (`ou_xxx`) or group ID (`oc_xxx`)
See [Get group/user IDs](#get-groupuser-ids) for lookup tips.

View File

@@ -101,6 +101,7 @@ Use Tailscale Serve for the private dashboard and Funnel for the public webhook
If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy.
5. **Verify the configuration:**
```bash
tailscale serve status
tailscale funnel status
@@ -225,6 +226,7 @@ This means the webhook handler isn't registered. Common causes:
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
3. **Gateway not restarted**: After adding config, restart the gateway:
```bash
openclaw gateway restart
```

View File

@@ -39,12 +39,13 @@ otherwise -> reply
![Group message flow](/images/groups-flow.svg)
If you want...
| Goal | What to set |
|------|-------------|
| Allow all groups but only reply on @mentions | `groups: { "*": { requireMention: true } }` |
| Disable all group replies | `groupPolicy: "disabled"` |
| Only specific groups | `groups: { "<group-id>": { ... } }` (no `"*"` key) |
| Only you can trigger in groups | `groupPolicy: "allowlist"`, `groupAllowFrom: ["+1555..."]` |
| Goal | What to set |
| -------------------------------------------- | ---------------------------------------------------------- |
| Allow all groups but only reply on @mentions | `groups: { "*": { requireMention: true } }` |
| Disable all group replies | `groupPolicy: "disabled"` |
| Only specific groups | `groups: { "<group-id>": { ... } }` (no `"*"` key) |
| Only you can trigger in groups | `groupPolicy: "allowlist"`, `groupAllowFrom: ["+1555..."]` |
## Session keys
@@ -370,4 +371,4 @@ The agent system prompt includes a group intro on the first turn of a new group
## WhatsApp specifics
See [Group messages](/concepts/group-messages) for WhatsApp-only behavior (history injection, mention handling details).
See [Group messages](/channels/group-messages) for WhatsApp-only behavior (history injection, mention handling details).

View File

@@ -62,6 +62,28 @@ Disable with:
- Automation permission when sending.
- `channels.imessage.cliPath` can point to any command that proxies stdin/stdout (for example, a wrapper script that SSHes to another Mac and runs `imsg rpc`).
## Troubleshooting macOS Privacy and Security TCC
If sending/receiving fails (for example, `imsg rpc` exits non-zero, times out, or the gateway appears to hang), a common cause is a macOS permission prompt that was never approved.
macOS grants TCC permissions per app/process context. Approve prompts in the same context that runs `imsg` (for example, Terminal/iTerm, a LaunchAgent session, or an SSH-launched process).
Checklist:
- **Full Disk Access**: allow access for the process running OpenClaw (and any shell/SSH wrapper that executes `imsg`). This is required to read the Messages database (`chat.db`).
- **Automation → Messages**: allow the process running OpenClaw (and/or your terminal) to control **Messages.app** for outbound sends.
- **`imsg` CLI health**: verify `imsg` is installed and supports RPC (`imsg rpc --help`).
Tip: If OpenClaw is running headless (LaunchAgent/systemd/SSH) the macOS prompt can be easy to miss. Run a one-time interactive command in a GUI terminal to force the prompt, then retry:
```bash
imsg chats --limit 1
# or
imsg send <handle> "test"
```
Related macOS folder permissions (Desktop/Documents/Downloads): [/platforms/mac/permissions](/platforms/mac/permissions).
## Setup (fast path)
1. Ensure Messages is signed in on this Mac.
@@ -81,7 +103,7 @@ If you want the bot to send from a **separate iMessage identity** (and keep your
6. Set up SSH so `ssh <bot-macos-user>@localhost true` works without a password.
7. Point `channels.imessage.accounts.bot.cliPath` at an SSH wrapper that runs `imsg` as the bot user.
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the _bot macOS user_. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry.
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the _bot macOS user_. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry. See [Troubleshooting macOS Privacy and Security TCC](#troubleshooting-macos-privacy-and-security-tcc).
Example wrapper (`chmod +x`). Replace `<bot-macos-user>` with your actual macOS username:
@@ -202,7 +224,7 @@ DMs:
- Approve via:
- `openclaw pairing list imessage`
- `openclaw pairing approve imessage <CODE>`
- Pairing is the default token exchange for iMessage DMs. Details: [Pairing](/start/pairing)
- Pairing is the default token exchange for iMessage DMs. Details: [Pairing](/channels/pairing)
Groups:

View File

@@ -39,7 +39,7 @@ Text is supported everywhere; media and reactions vary by channel.
- Channels can run simultaneously; configure multiple and OpenClaw will route per chat.
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
stores more state on disk.
- Group behavior varies by channel; see [Groups](/concepts/groups).
- Group behavior varies by channel; see [Groups](/channels/groups).
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
- Telegram internals: [grammY notes](/channels/grammy).
- Troubleshooting: [Channel troubleshooting](/channels/troubleshooting).

View File

@@ -34,7 +34,7 @@ openclaw plugins install ./extensions/line
## Setup
1. Create a LINE Developers account and open the Console:
https://developers.line.biz/console/
[https://developers.line.biz/console/](https://developers.line.biz/console/)
2. Create (or pick) a Provider and add a **Messaging API** channel.
3. Copy the **Channel access token** and **Channel secret** from the channel settings.
4. Enable **Use webhook** in the Messaging API settings.

View File

@@ -34,7 +34,7 @@ openclaw plugins install ./extensions/matrix
If you choose Matrix during configure/onboarding and a git checkout is detected,
OpenClaw will offer the local install path automatically.
Details: [Plugins](/plugin)
Details: [Plugins](/tools/plugin)
## Setup
@@ -74,7 +74,7 @@ Details: [Plugins](/plugin)
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
5. Restart the gateway (or finish onboarding).
6. Start a DM with the bot or invite it to a room from any Matrix client
(Element, Beeper, etc.; see https://matrix.org/ecosystem/clients/). Beeper requires E2EE,
(Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE,
so set `channels.matrix.encryption: true` and verify the device.
Minimal config (access token, user ID auto-fetched):
@@ -202,6 +202,32 @@ Once verified, the bot can decrypt messages in encrypted rooms.
| Location | ✅ Supported (geo URI; altitude ignored) |
| Native commands | ✅ Supported |
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list matrix
```
Common failures:
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
- Encrypted rooms fail: crypto support or encryption settings mismatch.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration)

View File

@@ -31,7 +31,7 @@ openclaw plugins install ./extensions/mattermost
If you choose Mattermost during configure/onboarding and a git checkout is detected,
OpenClaw will offer the local install path automatically.
Details: [Plugins](/plugin)
Details: [Plugins](/tools/plugin)
## Quick setup

View File

@@ -36,7 +36,7 @@ openclaw plugins install ./extensions/msteams
If you choose Teams during configure/onboarding and a git checkout is detected,
OpenClaw will offer the local install path automatically.
Details: [Plugins](/plugin)
Details: [Plugins](/tools/plugin)
## Quick setup (beginner)
@@ -558,6 +558,7 @@ Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint do
```
4. **Configure OpenClaw:**
```json5
{
channels: {
@@ -747,7 +748,7 @@ Bots have limited support in private channels:
- **"Icon file cannot be empty":** The manifest references icon files that are 0 bytes. Create valid PNG icons (32x32 for `outline.png`, 192x192 for `color.png`).
- **"webApplicationInfo.Id already in use":** The app is still installed in another team/chat. Find and uninstall it first, or wait 5-10 minutes for propagation.
- **"Something went wrong" on upload:** Upload via https://admin.teams.microsoft.com instead, open browser DevTools (F12) → Network tab, and check the response body for the actual error.
- **"Something went wrong" on upload:** Upload via [https://admin.teams.microsoft.com](https://admin.teams.microsoft.com) instead, open browser DevTools (F12) → Network tab, and check the response body for the actual error.
- **Sideload failing:** Try "Upload an app to your org's app catalog" instead of "Upload a custom app" - this often bypasses sideload restrictions.
### RSC permissions not working

View File

@@ -28,15 +28,17 @@ openclaw plugins install ./extensions/nextcloud-talk
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
OpenClaw will offer the local install path automatically.
Details: [Plugins](/plugin)
Details: [Plugins](/tools/plugin)
## Quick setup (beginner)
1. Install the Nextcloud Talk plugin.
2. On your Nextcloud server, create a bot:
```bash
./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction
```
3. Enable the bot in the target room settings.
4. Configure OpenClaw:
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`

View File

@@ -52,6 +52,23 @@ Treat these as sensitive (they gate access to your assistant).
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
creates a device pairing request that must be approved.
### Pair via Telegram (recommended for iOS)
If you use the `device-pair` plugin, you can do first-time device pairing entirely from Telegram:
1. In Telegram, message your bot: `/pair`
2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram).
3. On your phone, open the OpenClaw iOS app → Settings → Gateway.
4. Paste the setup code and connect.
5. Back in Telegram: `/pair approve`
The setup code is a base64-encoded JSON payload that contains:
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
- `token`: a short-lived pairing token
Treat the setup code like a password while it is valid.
### Approve a node device
```bash
@@ -60,7 +77,7 @@ openclaw devices approve <requestId>
openclaw devices reject <requestId>
```
### Where the state lives
### Node pairing state storage
Stored under `~/.openclaw/devices/`:

View File

@@ -109,7 +109,7 @@ DMs:
- Approve via:
- `openclaw pairing list signal`
- `openclaw pairing approve signal <CODE>`
- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/start/pairing)
- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/channels/pairing)
- UUID-only senders (from `sourceUuid`) are stored as `uuid:<id>` in `channels.signal.allowFrom`.
Groups:
@@ -168,6 +168,32 @@ Config:
- Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account).
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list signal
```
Common failures:
- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode.
- DMs ignored: sender is pending pairing approval.
- Group messages ignored: group sender/mention gating blocks delivery.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Configuration reference (Signal)
Full configuration: [Configuration](/gateway/configuration)

View File

@@ -30,7 +30,7 @@ Minimal config:
### Setup
1. Create a Slack app (From scratch) in https://api.slack.com/apps.
1. Create a Slack app (From scratch) in [https://api.slack.com/apps](https://api.slack.com/apps).
2. **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3. **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4. Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
@@ -49,7 +49,7 @@ Use the manifest below so scopes and events stay in sync.
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
### OpenClaw config (minimal)
### OpenClaw config (Socket mode)
Set tokens via env vars (recommended):
@@ -130,7 +130,7 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
### Setup
### Setup (HTTP mode)
1. Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
2. **Basic Information** → copy the **Signing Secret**.
@@ -260,30 +260,30 @@ If you enable native commands, add one `slash_commands` entry per command you wa
Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview.
[https://docs.slack.dev/apis/web-api/using-the-conversations-api/](https://docs.slack.dev/apis/web-api/using-the-conversations-api/) for the overview.
### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`)
https://docs.slack.dev/reference/methods/chat.postMessage
[https://docs.slack.dev/reference/methods/chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage)
- `im:write` (open DMs via `conversations.open` for user DMs)
https://docs.slack.dev/reference/methods/conversations.open
[https://docs.slack.dev/reference/methods/conversations.open](https://docs.slack.dev/reference/methods/conversations.open)
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
https://docs.slack.dev/reference/methods/conversations.history
[https://docs.slack.dev/reference/methods/conversations.history](https://docs.slack.dev/reference/methods/conversations.history)
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
https://docs.slack.dev/reference/methods/conversations.info
[https://docs.slack.dev/reference/methods/conversations.info](https://docs.slack.dev/reference/methods/conversations.info)
- `users:read` (user lookup)
https://docs.slack.dev/reference/methods/users.info
[https://docs.slack.dev/reference/methods/users.info](https://docs.slack.dev/reference/methods/users.info)
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
https://docs.slack.dev/reference/methods/reactions.get
https://docs.slack.dev/reference/methods/reactions.add
[https://docs.slack.dev/reference/methods/reactions.get](https://docs.slack.dev/reference/methods/reactions.get)
[https://docs.slack.dev/reference/methods/reactions.add](https://docs.slack.dev/reference/methods/reactions.add)
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
https://docs.slack.dev/reference/scopes/pins.read
https://docs.slack.dev/reference/scopes/pins.write
[https://docs.slack.dev/reference/scopes/pins.read](https://docs.slack.dev/reference/scopes/pins.read)
[https://docs.slack.dev/reference/scopes/pins.write](https://docs.slack.dev/reference/scopes/pins.write)
- `emoji:read` (`emoji.list`)
https://docs.slack.dev/reference/scopes/emoji.read
[https://docs.slack.dev/reference/scopes/emoji.read](https://docs.slack.dev/reference/scopes/emoji.read)
- `files:write` (uploads via `files.uploadV2`)
https://docs.slack.dev/messaging/working-with-files/#upload
[https://docs.slack.dev/messaging/working-with-files/#upload](https://docs.slack.dev/messaging/working-with-files/#upload)
### User token scopes (optional, read-only by default)
@@ -302,9 +302,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
- `chat:write.public` (only if we want to post to channels the bot isn't in)
https://docs.slack.dev/reference/scopes/chat.write.public
[https://docs.slack.dev/reference/scopes/chat.write.public](https://docs.slack.dev/reference/scopes/chat.write.public)
- `users:read.email` (only if we need email fields from `users.info`)
https://docs.slack.dev/changelog/2017-04-narrowing-email-access
[https://docs.slack.dev/changelog/2017-04-narrowing-email-access](https://docs.slack.dev/changelog/2017-04-narrowing-email-access)
- `files:read` (only if we start listing/reading file metadata)
## Config
@@ -537,6 +537,32 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
`files:write`) or those operations will fail.
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list slack
```
Common failures:
- Connected but no channel replies: channel blocked by `groupPolicy` or not in `channels.slack.channels` allowlist.
- DMs ignored: sender not approved when `channels.slack.dm.policy="pairing"`.
- API errors (`missing_scope`, `not_in_channel`, auth failures): bot/app tokens or Slack scopes are incomplete.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Notes
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.

View File

@@ -147,7 +147,7 @@ You can add custom commands to the menu via config:
}
```
## Troubleshooting
## Setup troubleshooting (commands)
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
@@ -157,10 +157,21 @@ More help: [Channel troubleshooting](/channels/troubleshooting).
Notes:
- Custom commands are **menu entries only**; OpenClaw does not implement them unless you handle them elsewhere.
- Some commands can be handled by plugins/skills without being registered in Telegrams command menu. These still work when typed (they just won't show up in `/commands` / the menu).
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (132 chars).
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
### Device pairing commands (`device-pair` plugin)
If the `device-pair` plugin is installed, it adds a Telegram-first flow for pairing a new phone:
1. `/pair` generates a setup code (sent as a separate message for easy copy/paste).
2. Paste the setup code in the iOS app to connect.
3. `/pair approve` approves the latest pending device request.
More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios).
## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
@@ -351,7 +362,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
- Approve via:
- `openclaw pairing list telegram`
- `openclaw pairing approve telegram <CODE>`
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/channels/pairing)
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human senders ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
#### Finding your Telegram user ID
@@ -365,6 +376,7 @@ Alternate (official Bot API):
1. DM your bot.
2. Fetch updates with your bot token and read `message.from.id`:
```bash
curl "https://api.telegram.org/bot<bot_token>/getUpdates"
```

View File

@@ -30,7 +30,7 @@ Local checkout (when running from a git repo):
openclaw plugins install ./extensions/tlon
```
Details: [Plugins](/plugin)
Details: [Plugins](/tools/plugin)
## Setup

View File

@@ -1,29 +1,116 @@
---
summary: "Channel-specific troubleshooting shortcuts (Discord/Telegram/WhatsApp)"
summary: "Fast channel level troubleshooting with per channel failure signatures and fixes"
read_when:
- A channel connects but messages dont flow
- Investigating channel misconfiguration (intents, permissions, privacy mode)
- Channel transport says connected but replies fail
- You need channel specific checks before deep provider docs
title: "Channel Troubleshooting"
---
# Channel troubleshooting
Start with:
Use this page when a channel connects but behavior is wrong.
## Command ladder
Run these in order first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
`channels status --probe` prints warnings when it can detect common channel misconfigurations, and includes small live checks (credentials, some permissions/membership).
Healthy baseline:
## Channels
- `Runtime: running`
- `RPC probe: ok`
- Channel probe shows connected/ready
- Discord: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
- Telegram: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
- WhatsApp: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
## WhatsApp
## Telegram quick fixes
### WhatsApp failure signatures
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
| Symptom | Fastest check | Fix |
| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. |
| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. |
| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. |
Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
## Telegram
### Telegram failure signatures
| Symptom | Fastest check | Fix |
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
## Discord
### Discord failure signatures
| Symptom | Fastest check | Fix |
| ------------------------------- | ----------------------------------- | --------------------------------------------------------- |
| Bot online but no guild replies | `openclaw channels status --probe` | Allow guild/channel and verify message content intent. |
| Group messages ignored | Check logs for mention gating drops | Mention bot or set guild/channel `requireMention: false`. |
| DM replies missing | `openclaw pairing list discord` | Approve DM pairing or adjust DM policy. |
Full troubleshooting: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
## Slack
### Slack failure signatures
| Symptom | Fastest check | Fix |
| -------------------------------------- | ----------------------------------------- | ------------------------------------------------- |
| Socket mode connected but no responses | `openclaw channels status --probe` | Verify app token + bot token and required scopes. |
| DMs blocked | `openclaw pairing list slack` | Approve pairing or relax DM policy. |
| Channel message ignored | Check `groupPolicy` and channel allowlist | Allow the channel or switch policy to `open`. |
Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubleshooting)
## iMessage and BlueBubbles
### iMessage and BlueBubbles failure signatures
| Symptom | Fastest check | Fix |
| -------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- |
| No inbound events | Verify webhook/server reachability and app permissions | Fix webhook URL or BlueBubbles server state. |
| Can send but no receive on macOS | Check macOS privacy permissions for Messages automation | Re-grant TCC permissions and restart channel process. |
| DM sender blocked | `openclaw pairing list imessage` or `openclaw pairing list bluebubbles` | Approve pairing or update allowlist. |
Full troubleshooting:
- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc)
- [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting)
## Signal
### Signal failure signatures
| Symptom | Fastest check | Fix |
| ------------------------------- | ------------------------------------------ | -------------------------------------------------------- |
| Daemon reachable but bot silent | `openclaw channels status --probe` | Verify `signal-cli` daemon URL/account and receive mode. |
| DM blocked | `openclaw pairing list signal` | Approve sender or adjust DM policy. |
| Group replies do not trigger | Check group allowlist and mention patterns | Add sender/group or loosen gating. |
Full troubleshooting: [/channels/signal#troubleshooting](/channels/signal#troubleshooting)
## Matrix
### Matrix failure signatures
| Symptom | Fastest check | Fix |
| ----------------------------------- | -------------------------------------------- | ----------------------------------------------- |
| Logged in but ignores room messages | `openclaw channels status --probe` | Check `groupPolicy` and room allowlist. |
| DMs do not process | `openclaw pairing list matrix` | Approve sender or adjust DM policy. |
| Encrypted rooms fail | Verify crypto module and encryption settings | Enable encryption support and rejoin/sync room. |
Full troubleshooting: [/channels/matrix#troubleshooting](/channels/matrix#troubleshooting)

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