Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbc4d093e | ||
|
|
0c1ac41085 | ||
|
|
a8c204e9fa | ||
|
|
1ac6f3ce92 | ||
|
|
b621d4550b | ||
|
|
769b286cf2 | ||
|
|
690bb192e6 | ||
|
|
601a052216 | ||
|
|
bf3021d266 | ||
|
|
145b2e5f52 | ||
|
|
4b73dc95c4 | ||
|
|
e17cb408a5 | ||
|
|
3cf92152c3 | ||
|
|
c50cde2170 | ||
|
|
7e0bebd669 | ||
|
|
7c49326191 | ||
|
|
744d1329cb | ||
|
|
42e6ff4611 | ||
|
|
5f21bf735a | ||
|
|
ee380e9ab9 | ||
|
|
ab340c82fb | ||
|
|
57dd0505a3 | ||
|
|
d6f9f1c79a | ||
|
|
a08a772ffc | ||
|
|
2622b1936b | ||
|
|
c0457e0cc4 | ||
|
|
ee2f0a175a | ||
|
|
60efe8ed7b | ||
|
|
332a20d9cc | ||
|
|
f16b0cf80d | ||
|
|
a4ee933022 | ||
|
|
cf7437cb4c | ||
|
|
081123c0e4 | ||
|
|
5fe3c36471 | ||
|
|
e06158c645 | ||
|
|
19a8547ecd | ||
|
|
32ae4566c6 | ||
|
|
be6a3d4caf | ||
|
|
1db0384090 | ||
|
|
d024dceef7 | ||
|
|
5ec499e14c | ||
|
|
0b350d78d5 | ||
|
|
96ee027371 | ||
|
|
ffcf3263c1 | ||
|
|
cef6b16d14 | ||
|
|
d06d440086 | ||
|
|
415fc9092e | ||
|
|
0be9d773cb | ||
|
|
ecb45660e9 | ||
|
|
f6fefd7f5f | ||
|
|
4206b9684b | ||
|
|
a4aad1c76a | ||
|
|
9464774133 | ||
|
|
be7191879a | ||
|
|
7252938339 | ||
|
|
810394f43b | ||
|
|
835162fb62 | ||
|
|
82883095fe | ||
|
|
49d8ad3049 | ||
|
|
1721d04405 | ||
|
|
633e0d9382 | ||
|
|
e156320c51 | ||
|
|
f06ce98312 | ||
|
|
11b07f4a29 |
No files matched your search
+62
-13
@@ -2,30 +2,50 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.17-7
|
## 2026.1.18-5
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
|
|
||||||
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
|
|
||||||
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
|
|
||||||
- Control UI: add gateway/node target selector for exec approvals.
|
|
||||||
- Docs: add approvals/service references and refresh node/control UI docs.
|
|
||||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
|
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||||
|
- Agents: retry compaction reset once, then surface a user-facing error on repeat failure. (#1187) — thanks @fayrose.
|
||||||
|
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||||
|
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||||
|
|
||||||
|
## 2026.1.18-4
|
||||||
|
|
||||||
|
### Changes
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
|
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||||
|
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||||
|
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
|
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||||
|
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
|
||||||
|
## 2026.1.18-3
|
||||||
|
|
||||||
|
### Changes
|
||||||
- Exec: add host/security/ask routing for gateway + node exec.
|
- Exec: add host/security/ask routing for gateway + node exec.
|
||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||||
- Hooks: run `BOOT.md` on gateway startup with the boot-md hook. (#1164) — thanks @ngutman.
|
|
||||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||||
@@ -40,21 +60,34 @@ Docs: https://docs.clawd.bot
|
|||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
|
||||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
|
||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
|
||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
|
||||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||||
- Exec approvals: parse command tokens correctly for PATH and relative resolution.
|
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||||
|
|
||||||
|
## 2026.1.18-2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
|
||||||
|
## 2026.1.18-1
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
|
### Fixes
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
|
||||||
## 2026.1.17-6
|
## 2026.1.17-6
|
||||||
@@ -83,6 +116,22 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||||
|
|
||||||
|
## 2026.1.18-1
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
## 2026.1.17-3
|
## 2026.1.17-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -474,25 +474,25 @@ Core contributors:
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/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/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||||
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/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/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
|
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||||
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/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/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/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
||||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/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/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/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/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/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/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/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/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/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||||
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||||
<a href="https://github.com/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||||
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/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=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||||
<a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Clawdbot macOS app (dev + signing)
|
||||||
|
|
||||||
|
## Quick dev run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from repo root
|
||||||
|
scripts/restart-mac.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
|
||||||
|
scripts/restart-mac.sh --sign # force code signing (requires cert)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/package-mac-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
||||||
|
|
||||||
|
## Signing behavior
|
||||||
|
|
||||||
|
Auto-selects identity (first match):
|
||||||
|
1) Developer ID Application
|
||||||
|
2) Apple Distribution
|
||||||
|
3) Apple Development
|
||||||
|
4) first available identity
|
||||||
|
|
||||||
|
If none found:
|
||||||
|
- errors by default
|
||||||
|
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
|
||||||
|
|
||||||
|
## Team ID audit (Sparkle mismatch guard)
|
||||||
|
|
||||||
|
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
|
||||||
|
If any embedded binary has a different Team ID, signing fails.
|
||||||
|
|
||||||
|
Skip the audit:
|
||||||
|
```bash
|
||||||
|
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library validation workaround (dev only)
|
||||||
|
|
||||||
|
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
|
||||||
|
Use for local dev only; keep off for release builds.
|
||||||
|
|
||||||
|
## Useful env flags
|
||||||
|
|
||||||
|
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
|
||||||
|
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
|
||||||
|
- `CODESIGN_TIMESTAMP=off` (offline debug)
|
||||||
|
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
|
||||||
|
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
|
||||||
@@ -8,6 +8,8 @@ struct BridgeNodeInfo: Sendable {
|
|||||||
var displayName: String?
|
var displayName: String?
|
||||||
var platform: String?
|
var platform: String?
|
||||||
var version: String?
|
var version: String?
|
||||||
|
var coreVersion: String?
|
||||||
|
var uiVersion: String?
|
||||||
var deviceFamily: String?
|
var deviceFamily: String?
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
@@ -147,6 +149,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
|
coreVersion: hello.coreVersion,
|
||||||
|
uiVersion: hello.uiVersion,
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
remoteAddress: self.remoteAddressString(),
|
remoteAddress: self.remoteAddressString(),
|
||||||
@@ -171,6 +175,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: req.displayName,
|
displayName: req.displayName,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
version: req.version,
|
version: req.version,
|
||||||
|
coreVersion: req.coreVersion,
|
||||||
|
uiVersion: req.uiVersion,
|
||||||
deviceFamily: req.deviceFamily,
|
deviceFamily: req.deviceFamily,
|
||||||
modelIdentifier: req.modelIdentifier,
|
modelIdentifier: req.modelIdentifier,
|
||||||
caps: req.caps,
|
caps: req.caps,
|
||||||
@@ -186,6 +192,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: enriched.displayName,
|
displayName: enriched.displayName,
|
||||||
platform: enriched.platform,
|
platform: enriched.platform,
|
||||||
version: enriched.version,
|
version: enriched.version,
|
||||||
|
coreVersion: enriched.coreVersion,
|
||||||
|
uiVersion: enriched.uiVersion,
|
||||||
deviceFamily: enriched.deviceFamily,
|
deviceFamily: enriched.deviceFamily,
|
||||||
modelIdentifier: enriched.modelIdentifier,
|
modelIdentifier: enriched.modelIdentifier,
|
||||||
remoteAddress: enriched.remoteAddress,
|
remoteAddress: enriched.remoteAddress,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import CryptoKit
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -27,6 +28,49 @@ private struct ExecApprovalSocketDecision: Codable {
|
|||||||
var decision: ExecApprovalDecision
|
var decision: ExecApprovalDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostSocketRequest: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var nonce: String
|
||||||
|
var ts: Int
|
||||||
|
var hmac: String
|
||||||
|
var requestJson: String
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostRequest: Codable {
|
||||||
|
var command: [String]
|
||||||
|
var rawCommand: String?
|
||||||
|
var cwd: String?
|
||||||
|
var env: [String: String]?
|
||||||
|
var timeoutMs: Int?
|
||||||
|
var needsScreenRecording: Bool?
|
||||||
|
var agentId: String?
|
||||||
|
var sessionKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostRunResult: Codable {
|
||||||
|
var exitCode: Int?
|
||||||
|
var timedOut: Bool
|
||||||
|
var success: Bool
|
||||||
|
var stdout: String
|
||||||
|
var stderr: String
|
||||||
|
var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostError: Codable {
|
||||||
|
var code: String
|
||||||
|
var message: String
|
||||||
|
var reason: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostResponse: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var ok: Bool
|
||||||
|
var payload: ExecHostRunResult?
|
||||||
|
var error: ExecHostError?
|
||||||
|
}
|
||||||
|
|
||||||
enum ExecApprovalsSocketClient {
|
enum ExecApprovalsSocketClient {
|
||||||
private struct TimeoutError: LocalizedError {
|
private struct TimeoutError: LocalizedError {
|
||||||
var message: String
|
var message: String
|
||||||
@@ -146,6 +190,9 @@ final class ExecApprovalsPromptServer {
|
|||||||
token: approvals.token,
|
token: approvals.token,
|
||||||
onPrompt: { request in
|
onPrompt: { request in
|
||||||
await ExecApprovalsPromptPresenter.prompt(request)
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
|
},
|
||||||
|
onExec: { request in
|
||||||
|
await ExecHostExecutor.handle(request)
|
||||||
})
|
})
|
||||||
server.start()
|
server.start()
|
||||||
self.server = server
|
self.server = server
|
||||||
@@ -206,11 +253,182 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
fileprivate enum ExecHostExecutor {
|
||||||
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
|
"PATH",
|
||||||
|
"NODE_OPTIONS",
|
||||||
|
"PYTHONHOME",
|
||||||
|
"PYTHONPATH",
|
||||||
|
"PERL5LIB",
|
||||||
|
"PERL5OPT",
|
||||||
|
"RUBYOPT",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let blockedEnvPrefixes: [String] = [
|
||||||
|
"DYLD_",
|
||||||
|
"LD_",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
guard !command.isEmpty else {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayCommand = ExecCommandFormatter.displayString(
|
||||||
|
for: command,
|
||||||
|
rawCommand: request.rawCommand)
|
||||||
|
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
||||||
|
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
||||||
|
let security = approvals.agent.security
|
||||||
|
let ask = approvals.agent.ask
|
||||||
|
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||||
|
let env = self.sanitizedEnv(request.env)
|
||||||
|
let resolution = ExecCommandResolution.resolve(
|
||||||
|
command: command,
|
||||||
|
rawCommand: request.rawCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: env)
|
||||||
|
let allowlistMatch = security == .allowlist
|
||||||
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
|
: nil
|
||||||
|
let skillAllow: Bool
|
||||||
|
if autoAllowSkills, let name = resolution?.executableName {
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins()
|
||||||
|
skillAllow = bins.contains(name)
|
||||||
|
} else {
|
||||||
|
skillAllow = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if security == .deny {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let requiresAsk: Bool = {
|
||||||
|
if ask == .always { return true }
|
||||||
|
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
var approvedByAsk = false
|
||||||
|
if requiresAsk {
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: displayCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: security.rawValue,
|
||||||
|
ask: ask.rawValue,
|
||||||
|
agentId: trimmedAgent,
|
||||||
|
resolvedPath: resolution?.resolvedPath))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
if security == .allowlist {
|
||||||
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
if !pattern.isEmpty {
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: trimmedAgent,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: displayCommand,
|
||||||
|
resolvedPath: resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.needsScreenRecording == true {
|
||||||
|
let authorized = await PermissionManager
|
||||||
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
|
if !authorized {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
|
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||||
|
await ShellExecutor.runDetailed(
|
||||||
|
command: command,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: env,
|
||||||
|
timeout: timeoutSec)
|
||||||
|
}.value
|
||||||
|
let payload = ExecHostRunResult(
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
success: result.success,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
error: result.errorMessage)
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: true,
|
||||||
|
payload: payload,
|
||||||
|
error: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||||
|
guard let overrides else { return nil }
|
||||||
|
var merged = ProcessInfo.processInfo.environment
|
||||||
|
for (rawKey, value) in overrides {
|
||||||
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !key.isEmpty else { continue }
|
||||||
|
let upper = key.uppercased()
|
||||||
|
if self.blockedEnvKeys.contains(upper) { continue }
|
||||||
|
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||||
|
merged[key] = value
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
private let socketPath: String
|
private let socketPath: String
|
||||||
private let token: String
|
private let token: String
|
||||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
|
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
||||||
private var socketFD: Int32 = -1
|
private var socketFD: Int32 = -1
|
||||||
private var acceptTask: Task<Void, Never>?
|
private var acceptTask: Task<Void, Never>?
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
@@ -218,11 +436,13 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
init(
|
init(
|
||||||
socketPath: String,
|
socketPath: String,
|
||||||
token: String,
|
token: String,
|
||||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
|
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
||||||
|
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
||||||
{
|
{
|
||||||
self.socketPath = socketPath
|
self.socketPath = socketPath
|
||||||
self.token = token
|
self.token = token
|
||||||
self.onPrompt = onPrompt
|
self.onPrompt = onPrompt
|
||||||
|
self.onExec = onExec
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -317,26 +537,39 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
private func handleClient(fd: Int32) async {
|
private func handleClient(fd: Int32) async {
|
||||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
do {
|
do {
|
||||||
|
guard self.isAllowedPeer(fd: fd) else {
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
let data = line.data(using: .utf8)
|
let data = line.data(using: .utf8)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
guard
|
||||||
guard request.type == "request", request.token == self.token else {
|
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
let type = envelope["type"] as? String
|
||||||
let data = try JSONEncoder().encode(response)
|
else {
|
||||||
var payload = data
|
return
|
||||||
payload.append(0x0A)
|
}
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
|
if type == "request" {
|
||||||
|
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||||
|
guard request.token == self.token else {
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decision = await self.onPrompt(request.request)
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == "exec" {
|
||||||
|
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
|
||||||
|
let response = await self.handleExecRequest(request)
|
||||||
|
try self.sendExecResponse(handle: handle, response: response)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let decision = await self.onPrompt(request.request)
|
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
|
|
||||||
let responseData = try JSONEncoder().encode(response)
|
|
||||||
var payload = responseData
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -357,4 +590,77 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
return String(data: lineData, encoding: .utf8)
|
return String(data: lineData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sendApprovalResponse(
|
||||||
|
handle: FileHandle,
|
||||||
|
id: String,
|
||||||
|
decision: ExecApprovalDecision) throws
|
||||||
|
{
|
||||||
|
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAllowedPeer(fd: Int32) -> Bool {
|
||||||
|
var uid = uid_t(0)
|
||||||
|
var gid = gid_t(0)
|
||||||
|
if getpeereid(fd, &uid, &gid) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return uid == geteuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
|
||||||
|
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
if abs(nowMs - request.ts) > 10_000 {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
||||||
|
}
|
||||||
|
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
||||||
|
if expected != request.hmac {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
|
||||||
|
}
|
||||||
|
guard let requestData = request.requestJson.data(using: .utf8),
|
||||||
|
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
|
||||||
|
else {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
|
||||||
|
}
|
||||||
|
let response = await self.onExec(payload)
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: response.ok,
|
||||||
|
payload: response.payload,
|
||||||
|
error: response.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
|
||||||
|
let key = SymmetricKey(data: Data(self.token.utf8))
|
||||||
|
let message = "\(nonce):\(ts):\(requestJson)"
|
||||||
|
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
|
||||||
|
return mac.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +249,13 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cachedGatewayVersion() -> String? {
|
||||||
|
guard let snapshot = self.lastSnapshot else { return nil }
|
||||||
|
let raw = snapshot.server["version"]?.value as? String
|
||||||
|
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private weak var statusItem: NSStatusItem?
|
private weak var statusItem: NSStatusItem?
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
private var nodesLoadTask: Task<Void, Never>?
|
private var nodesLoadTask: Task<Void, Never>?
|
||||||
|
private var previewTasks: [Task<Void, Never>] = []
|
||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
@@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.menuOpenWidth = nil
|
self.menuOpenWidth = nil
|
||||||
self.loadTask?.cancel()
|
self.loadTask?.cancel()
|
||||||
self.nodesLoadTask?.cancel()
|
self.nodesLoadTask?.cancel()
|
||||||
|
self.cancelPreviewTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
@@ -107,6 +109,7 @@ extension MenuSessionsInjector {
|
|||||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
private func inject(into menu: NSMenu) {
|
||||||
|
self.cancelPreviewTasks()
|
||||||
// Remove any previous injected items.
|
// Remove any previous injected items.
|
||||||
for item in menu.items where item.tag == self.tag {
|
for item in menu.items where item.tag == self.tag {
|
||||||
menu.removeItem(item)
|
menu.removeItem(item)
|
||||||
@@ -280,9 +283,7 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||||
let rows = self.usageRows
|
let rows = self.usageRows
|
||||||
let errorText = self.cachedUsageErrorText
|
if rows.isEmpty {
|
||||||
|
|
||||||
if rows.isEmpty, errorText == nil {
|
|
||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,25 +307,6 @@ extension MenuSessionsInjector {
|
|||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
|
|
||||||
menu.insertItem(
|
|
||||||
self.makeMessageItem(
|
|
||||||
text: errorText,
|
|
||||||
symbolName: "exclamationmark.triangle",
|
|
||||||
width: width,
|
|
||||||
maxLines: 2),
|
|
||||||
at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows.isEmpty {
|
|
||||||
menu.insertItem(
|
|
||||||
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
|
||||||
at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
return cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
if let selectedProvider = self.selectedUsageProviderId,
|
if let selectedProvider = self.selectedUsageProviderId,
|
||||||
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
||||||
rows.count > 1
|
rows.count > 1
|
||||||
@@ -440,6 +422,8 @@ extension MenuSessionsInjector {
|
|||||||
displayName: "Gateway",
|
displayName: "Gateway",
|
||||||
platform: platform,
|
platform: platform,
|
||||||
version: nil,
|
version: nil,
|
||||||
|
coreVersion: nil,
|
||||||
|
uiVersion: nil,
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
remoteIp: host,
|
remoteIp: host,
|
||||||
@@ -473,15 +457,46 @@ extension MenuSessionsInjector {
|
|||||||
item.tag = self.tag
|
item.tag = self.tag
|
||||||
item.isEnabled = false
|
item.isEnabled = false
|
||||||
let view = AnyView(SessionMenuPreviewView(
|
let view = AnyView(SessionMenuPreviewView(
|
||||||
sessionKey: sessionKey,
|
|
||||||
width: width,
|
width: width,
|
||||||
maxItems: 10,
|
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
title: title))
|
title: title,
|
||||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
items: [],
|
||||||
|
status: .loading))
|
||||||
|
let hosting = NSHostingView(rootView: view)
|
||||||
|
hosting.frame.size.width = max(1, width)
|
||||||
|
let size = hosting.fittingSize
|
||||||
|
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||||
|
item.view = hosting
|
||||||
|
|
||||||
|
let task = Task { [weak hosting] in
|
||||||
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let hosting else { return }
|
||||||
|
let nextView = AnyView(SessionMenuPreviewView(
|
||||||
|
width: width,
|
||||||
|
maxLines: maxLines,
|
||||||
|
title: title,
|
||||||
|
items: snapshot.items,
|
||||||
|
status: snapshot.status))
|
||||||
|
hosting.rootView = nextView
|
||||||
|
hosting.invalidateIntrinsicContentSize()
|
||||||
|
hosting.frame.size.width = max(1, width)
|
||||||
|
let size = hosting.fittingSize
|
||||||
|
hosting.frame.size.height = size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.previewTasks.append(task)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cancelPreviewTasks() {
|
||||||
|
for task in self.previewTasks {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
self.previewTasks.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||||
let view = AnyView(
|
let view = AnyView(
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
@@ -559,14 +574,11 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||||
self.cachedUsageErrorText = nil
|
|
||||||
self.usageCacheUpdatedAt = Date()
|
|
||||||
} catch {
|
} catch {
|
||||||
if self.cachedUsageSummary == nil {
|
self.cachedUsageSummary = nil
|
||||||
self.cachedUsageErrorText = self.compactUsageError(error)
|
self.cachedUsageErrorText = nil
|
||||||
}
|
|
||||||
self.usageCacheUpdatedAt = Date()
|
|
||||||
}
|
}
|
||||||
|
self.usageCacheUpdatedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func compactUsageError(_ error: Error) -> String {
|
private func compactUsageError(_ error: Error) -> String {
|
||||||
@@ -747,8 +759,8 @@ extension MenuSessionsInjector {
|
|||||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let version = entry.version?.nonEmpty {
|
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ actor MacNodeBridgePairingClient {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
|
coreVersion: hello.coreVersion,
|
||||||
|
uiVersion: hello.uiVersion,
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
caps: hello.caps,
|
caps: hello.caps,
|
||||||
|
|||||||
@@ -114,12 +114,19 @@ final class MacNodeModeCoordinator {
|
|||||||
let caps = self.currentCaps()
|
let caps = self.currentCaps()
|
||||||
let commands = self.currentCommands(caps: caps)
|
let commands = self.currentCommands(caps: caps)
|
||||||
let permissions = await self.currentPermissions()
|
let permissions = await self.currentPermissions()
|
||||||
|
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||||
|
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
|
||||||
|
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
|
||||||
|
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return BridgeHello(
|
return BridgeHello(
|
||||||
nodeId: Self.nodeId(),
|
nodeId: Self.nodeId(),
|
||||||
displayName: InstanceIdentity.displayName,
|
displayName: InstanceIdentity.displayName,
|
||||||
token: token,
|
token: token,
|
||||||
platform: "macos",
|
platform: "macos",
|
||||||
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
version: uiVersion,
|
||||||
|
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
||||||
|
uiVersion: uiVersion,
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
caps: caps,
|
caps: caps,
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter {
|
|||||||
if let platform = self.platformText(entry) {
|
if let platform = self.platformText(entry) {
|
||||||
parts.append("platform \(platform)")
|
parts.append("platform \(platform)")
|
||||||
}
|
}
|
||||||
if let version = entry.version?.nonEmpty {
|
let versionLabels = self.versionLabels(entry)
|
||||||
parts.append("app \(self.compactVersion(version))")
|
if !versionLabels.isEmpty {
|
||||||
|
parts.append(versionLabels.joined(separator: " · "))
|
||||||
}
|
}
|
||||||
parts.append("status \(self.roleText(entry))")
|
parts.append("status \(self.roleText(entry))")
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
@@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||||
guard let version = entry.version?.nonEmpty else { return nil }
|
let labels = self.versionLabels(entry, compact: false)
|
||||||
return self.shortVersionLabel(version)
|
if labels.isEmpty { return nil }
|
||||||
|
return labels.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func platformText(_ entry: NodeInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
@@ -127,6 +129,39 @@ struct NodeMenuEntryFormatter {
|
|||||||
return compact
|
return compact
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
|
||||||
|
let (core, ui) = self.resolveVersions(entry)
|
||||||
|
var labels: [String] = []
|
||||||
|
if let core {
|
||||||
|
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
|
||||||
|
labels.append("core \(label)")
|
||||||
|
}
|
||||||
|
if let ui {
|
||||||
|
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
|
||||||
|
labels.append("ui \(label)")
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
|
||||||
|
let core = entry.coreVersion?.nonEmpty
|
||||||
|
let ui = entry.uiVersion?.nonEmpty
|
||||||
|
if core != nil || ui != nil {
|
||||||
|
return (core, ui)
|
||||||
|
}
|
||||||
|
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
|
||||||
|
if self.isHeadlessPlatform(entry) {
|
||||||
|
return (legacy, nil)
|
||||||
|
}
|
||||||
|
return (nil, legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
|
||||||
|
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||||
|
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) {
|
if self.isGateway(entry) {
|
||||||
return self.safeSystemSymbol(
|
return self.safeSystemSymbol(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct NodeInfo: Identifiable, Codable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let platform: String?
|
let platform: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
|
let coreVersion: String?
|
||||||
|
let uiVersion: String?
|
||||||
let deviceFamily: String?
|
let deviceFamily: String?
|
||||||
let modelIdentifier: String?
|
let modelIdentifier: String?
|
||||||
let remoteIp: String?
|
let remoteIp: String?
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
|||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
struct SessionPreviewItem: Identifiable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let role: PreviewRole
|
let role: PreviewRole
|
||||||
let text: String
|
let text: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum PreviewRole: String, Sendable {
|
enum PreviewRole: String, Sendable {
|
||||||
case user
|
case user
|
||||||
case assistant
|
case assistant
|
||||||
case tool
|
case tool
|
||||||
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor SessionPreviewCache {
|
actor SessionPreviewCache {
|
||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionMenuPreviewView: View {
|
#if DEBUG
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
extension SessionPreviewCache {
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||||
|
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||||
let sessionKey: String
|
|
||||||
let width: CGFloat
|
|
||||||
let maxItems: Int
|
|
||||||
let maxLines: Int
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
||||||
@State private var items: [SessionPreviewItem] = []
|
|
||||||
@State private var status: LoadStatus = .loading
|
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
|
||||||
var errorDescription: String? { "preview timeout" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum LoadStatus: Equatable {
|
func _testReset() {
|
||||||
|
self.entries = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct SessionMenuPreviewSnapshot: Sendable {
|
||||||
|
let items: [SessionPreviewItem]
|
||||||
|
let status: SessionMenuPreviewView.LoadStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionMenuPreviewView: View {
|
||||||
|
let width: CGFloat
|
||||||
|
let maxLines: Int
|
||||||
|
let title: String
|
||||||
|
let items: [SessionPreviewItem]
|
||||||
|
let status: LoadStatus
|
||||||
|
|
||||||
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
|
enum LoadStatus: Equatable {
|
||||||
case loading
|
case loading
|
||||||
case ready
|
case ready
|
||||||
case empty
|
case empty
|
||||||
@@ -78,15 +86,17 @@ struct SessionMenuPreviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var primaryColor: Color {
|
private var primaryColor: Color {
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
if self.isHighlighted {
|
||||||
|
return Color(nsColor: .selectedMenuItemTextColor)
|
||||||
|
}
|
||||||
|
return Color(nsColor: .labelColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var secondaryColor: Color {
|
private var secondaryColor: Color {
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
if self.isHighlighted {
|
||||||
}
|
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
||||||
|
}
|
||||||
private var previewLimit: Int {
|
return Color(nsColor: .secondaryLabelColor)
|
||||||
min(max(self.maxItems * 3, 20), 120)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -100,21 +110,19 @@ struct SessionMenuPreviewView: View {
|
|||||||
|
|
||||||
switch self.status {
|
switch self.status {
|
||||||
case .loading:
|
case .loading:
|
||||||
Text("Loading preview…")
|
self.placeholder("Loading preview…")
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(self.secondaryColor)
|
|
||||||
case .empty:
|
case .empty:
|
||||||
Text("No recent messages")
|
self.placeholder("No recent messages")
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(self.secondaryColor)
|
|
||||||
case let .error(message):
|
case let .error(message):
|
||||||
Text(message)
|
self.placeholder(message)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(self.secondaryColor)
|
|
||||||
case .ready:
|
case .ready:
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
if self.items.isEmpty {
|
||||||
ForEach(self.items) { item in
|
self.placeholder("No recent messages")
|
||||||
self.previewRow(item)
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(self.items) { item in
|
||||||
|
self.previewRow(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,9 +131,6 @@ struct SessionMenuPreviewView: View {
|
|||||||
.padding(.leading, 16)
|
.padding(.leading, 16)
|
||||||
.padding(.trailing, 11)
|
.padding(.trailing, 11)
|
||||||
.frame(width: max(1, self.width), alignment: .leading)
|
.frame(width: max(1, self.width), alignment: .leading)
|
||||||
.task(id: self.sessionKey) {
|
|
||||||
await self.loadPreview()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -157,55 +162,66 @@ struct SessionMenuPreviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPreview() async {
|
@ViewBuilder
|
||||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
private func placeholder(_ text: String) -> some View {
|
||||||
await MainActor.run {
|
Text(text)
|
||||||
self.items = cached
|
.font(.caption)
|
||||||
self.status = cached.isEmpty ? .empty : .ready
|
.foregroundStyle(self.primaryColor)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
}
|
||||||
self.status = .loading
|
|
||||||
|
enum SessionMenuPreviewLoader {
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
|
|
||||||
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
|
var errorDescription: String? { "preview timeout" }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
|
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||||
|
return Self.snapshot(from: cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
seconds: Self.previewTimeoutSeconds,
|
seconds: self.previewTimeoutSeconds,
|
||||||
onTimeout: { PreviewTimeoutError() },
|
onTimeout: { PreviewTimeoutError() },
|
||||||
operation: {
|
operation: {
|
||||||
try await GatewayConnection.shared.chatHistory(
|
try await GatewayConnection.shared.chatHistory(
|
||||||
sessionKey: self.sessionKey,
|
sessionKey: sessionKey,
|
||||||
limit: self.previewLimit,
|
limit: self.previewLimit(for: maxItems),
|
||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
})
|
})
|
||||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||||
await MainActor.run {
|
return Self.snapshot(from: built)
|
||||||
self.items = built
|
|
||||||
self.status = built.isEmpty ? .empty : .ready
|
|
||||||
}
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||||
await MainActor.run {
|
if let fallback {
|
||||||
if let fallback {
|
return Self.snapshot(from: fallback)
|
||||||
self.items = fallback
|
|
||||||
self.status = fallback.isEmpty ? .empty : .ready
|
|
||||||
} else {
|
|
||||||
self.status = .error("Preview unavailable")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||||
"error=\(errorDescription, privacy: .public)")
|
"error=\(errorDescription, privacy: .public)")
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
|
min(max(maxItems * 3, 20), 120)
|
||||||
|
}
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
|||||||
@@ -530,6 +530,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreversion: String?
|
||||||
|
public let uiversion: String?
|
||||||
public let devicefamily: String?
|
public let devicefamily: String?
|
||||||
public let modelidentifier: String?
|
public let modelidentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -542,6 +544,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
displayname: String?,
|
displayname: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreversion: String?,
|
||||||
|
uiversion: String?,
|
||||||
devicefamily: String?,
|
devicefamily: String?,
|
||||||
modelidentifier: String?,
|
modelidentifier: String?,
|
||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
@@ -553,6 +557,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
self.displayname = displayname
|
self.displayname = displayname
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreversion = coreversion
|
||||||
|
self.uiversion = uiversion
|
||||||
self.devicefamily = devicefamily
|
self.devicefamily = devicefamily
|
||||||
self.modelidentifier = modelidentifier
|
self.modelidentifier = modelidentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -565,6 +571,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
case displayname = "displayName"
|
case displayname = "displayName"
|
||||||
case platform
|
case platform
|
||||||
case version
|
case version
|
||||||
|
case coreversion = "coreVersion"
|
||||||
|
case uiversion = "uiVersion"
|
||||||
case devicefamily = "deviceFamily"
|
case devicefamily = "deviceFamily"
|
||||||
case modelidentifier = "modelIdentifier"
|
case modelidentifier = "modelIdentifier"
|
||||||
case caps
|
case caps
|
||||||
@@ -1652,6 +1660,40 @@ public struct ExecApprovalsSetParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
|
||||||
|
public let nodeid: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
nodeid: String
|
||||||
|
) {
|
||||||
|
self.nodeid = nodeid
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case nodeid = "nodeId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
|
||||||
|
public let nodeid: String
|
||||||
|
public let file: [String: AnyCodable]
|
||||||
|
public let basehash: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
nodeid: String,
|
||||||
|
file: [String: AnyCodable],
|
||||||
|
basehash: String?
|
||||||
|
) {
|
||||||
|
self.nodeid = nodeid
|
||||||
|
self.file = file
|
||||||
|
self.basehash = basehash
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case nodeid = "nodeId"
|
||||||
|
case file
|
||||||
|
case basehash = "baseHash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||||
public let path: String
|
public let path: String
|
||||||
public let exists: Bool
|
public let exists: Bool
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct SessionMenuPreviewTests {
|
||||||
|
@Test func loaderReturnsCachedItems() async {
|
||||||
|
await SessionPreviewCache.shared._testReset()
|
||||||
|
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||||
|
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||||
|
|
||||||
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
|
#expect(snapshot.status == .ready)
|
||||||
|
#expect(snapshot.items.count == 1)
|
||||||
|
#expect(snapshot.items.first?.text == "Hi")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||||
|
await SessionPreviewCache.shared._testReset()
|
||||||
|
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||||
|
|
||||||
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
|
#expect(snapshot.status == .empty)
|
||||||
|
#expect(snapshot.items.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
public let token: String?
|
public let token: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreVersion: String?
|
||||||
|
public let uiVersion: String?
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -76,6 +78,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
token: String?,
|
token: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreVersion: String? = nil,
|
||||||
|
uiVersion: String? = nil,
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -88,6 +92,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreVersion = coreVersion
|
||||||
|
self.uiVersion = uiVersion
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -121,6 +127,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
public let displayName: String?
|
public let displayName: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreVersion: String?
|
||||||
|
public let uiVersion: String?
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -135,6 +143,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreVersion: String? = nil,
|
||||||
|
uiVersion: String? = nil,
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -148,6 +158,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreVersion = coreVersion
|
||||||
|
self.uiVersion = uiVersion
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
|
|||||||
+14
-5
@@ -7,8 +7,8 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot memory`
|
# `clawdbot memory`
|
||||||
|
|
||||||
Memory search tools (semantic memory status/index/search).
|
Manage semantic memory indexing and search.
|
||||||
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
|
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Memory concept: [Memory](/concepts/memory)
|
- Memory concept: [Memory](/concepts/memory)
|
||||||
@@ -22,11 +22,20 @@ clawdbot memory status --deep
|
|||||||
clawdbot memory status --deep --index
|
clawdbot memory status --deep --index
|
||||||
clawdbot memory status --deep --index --verbose
|
clawdbot memory status --deep --index --verbose
|
||||||
clawdbot memory index
|
clawdbot memory index
|
||||||
|
clawdbot memory index --verbose
|
||||||
clawdbot memory search "release checklist"
|
clawdbot memory search "release checklist"
|
||||||
|
clawdbot memory status --agent main
|
||||||
|
clawdbot memory index --agent main --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
Common:
|
||||||
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
|
|
||||||
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
|
- `--agent <id>`: scope to a single agent (default: all configured agents).
|
||||||
|
- `--verbose`: emit detailed logs during probes and indexing.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `memory status --deep` probes vector + embedding availability.
|
||||||
|
- `memory status --deep --index` runs a reindex if the store is dirty.
|
||||||
|
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
||||||
+32
-18
@@ -79,37 +79,46 @@ semantic queries can find related notes even when wording differs.
|
|||||||
Defaults:
|
Defaults:
|
||||||
- Enabled by default.
|
- Enabled by default.
|
||||||
- Watches memory files for changes (debounced).
|
- Watches memory files for changes (debounced).
|
||||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
|
||||||
|
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
|
||||||
|
2. `openai` if an OpenAI key can be resolved.
|
||||||
|
3. `gemini` if a Gemini key can be resolved.
|
||||||
|
4. Otherwise memory search stays disabled until configured.
|
||||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||||
|
|
||||||
Remote embeddings **require** an API key for the embedding provider. By default
|
Remote embeddings **require** an API key for the embedding provider. Clawdbot
|
||||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
||||||
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
||||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
||||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
||||||
|
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
|
|
||||||
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
|
### Gemini embeddings (native)
|
||||||
|
|
||||||
|
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
model: "gemini-embedding-001", // default
|
model: "gemini-embedding-001",
|
||||||
remote: {
|
remote: {
|
||||||
apiKey: "${GEMINI_API_KEY}"
|
apiKey: "YOUR_GEMINI_API_KEY"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
|
Notes:
|
||||||
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
|
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
|
||||||
|
- `remote.headers` lets you add extra headers if needed.
|
||||||
|
- Default model: `gemini-embedding-001`.
|
||||||
|
|
||||||
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
|
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
|
||||||
you can use the `remote` configuration:
|
you can use the `remote` configuration with the OpenAI provider:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
@@ -118,8 +127,8 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://proxy.example/v1",
|
baseUrl: "https://api.example.com/v1/",
|
||||||
apiKey: "YOUR_PROXY_KEY",
|
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
|
||||||
headers: { "X-Custom-Header": "value" }
|
headers: { "X-Custom-Header": "value" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,11 +139,16 @@ agents: {
|
|||||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||||
`memorySearch.fallback = "none"`.
|
`memorySearch.fallback = "none"`.
|
||||||
|
|
||||||
Batch indexing (OpenAI only):
|
Fallbacks:
|
||||||
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
|
||||||
|
- The fallback provider is only used when the primary embedding provider fails.
|
||||||
|
|
||||||
|
Batch indexing (OpenAI + Gemini):
|
||||||
|
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||||
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||||
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
|
||||||
|
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
|
||||||
|
|
||||||
Why OpenAI batch is fast + cheap:
|
Why OpenAI batch is fast + cheap:
|
||||||
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||||
|
|
||||||
## Message envelopes (UTC)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped in an envelope like:
|
Inbound messages are wrapped in an envelope like:
|
||||||
|
|
||||||
@@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
|
|||||||
[Provider ... 2026-01-05T21:26Z] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||||
|
|
||||||
|
You can override this with:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||||
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
|
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||||
|
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||||
|
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**UTC (default):**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed timezone:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**Elapsed time:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
|
||||||
|
```
|
||||||
|
|
||||||
## Tool payloads (raw provider data + normalized fields)
|
## Tool payloads (raw provider data + normalized fields)
|
||||||
|
|
||||||
|
|||||||
+44
-4
@@ -7,10 +7,10 @@ read_when:
|
|||||||
|
|
||||||
# Date & Time
|
# Date & Time
|
||||||
|
|
||||||
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||||
We avoid rewriting provider timestamps so tools keep their native semantics.
|
Provider timestamps are preserved so tools keep their native semantics.
|
||||||
|
|
||||||
## Message envelopes (UTC)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||||
|
|
||||||
@@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
|||||||
[Provider ... 2026-01-05T21:26Z] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||||
|
|
||||||
|
You can override this behavior:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||||
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `envelopeTimezone: "local"` uses the host timezone.
|
||||||
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
|
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||||
|
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||||
|
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**UTC (default):**
|
||||||
|
|
||||||
|
```
|
||||||
|
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**User timezone:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[WhatsApp +1555 2026-01-18 00:19 CST] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**Elapsed time enabled:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
||||||
|
```
|
||||||
|
|
||||||
## System prompt: Current Date & Time
|
## System prompt: Current Date & Time
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
summary: Node + tsx "__name is not a function" crash notes and workarounds
|
||||||
|
read_when:
|
||||||
|
- Debugging Node-only dev scripts or watch mode failures
|
||||||
|
- Investigating tsx/esbuild loader crashes in Clawdbot
|
||||||
|
---
|
||||||
|
|
||||||
|
# Node + tsx "__name is not a function" crash
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Running Clawdbot via Node with `tsx` fails at startup with:
|
||||||
|
|
||||||
|
```
|
||||||
|
[clawdbot] Failed to start CLI: TypeError: __name is not a function
|
||||||
|
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
|
||||||
|
at .../src/agents/auth-profiles/constants.ts:25:20
|
||||||
|
```
|
||||||
|
|
||||||
|
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- Node: v25.x (observed on v25.3.0)
|
||||||
|
- tsx: 4.21.0
|
||||||
|
- OS: macOS (repro also likely on other platforms that run Node 25)
|
||||||
|
|
||||||
|
## Repro (Node-only)
|
||||||
|
```bash
|
||||||
|
# in repo root
|
||||||
|
node --version
|
||||||
|
pnpm install
|
||||||
|
node --import tsx src/entry.ts status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal repro in repo
|
||||||
|
```bash
|
||||||
|
node --import tsx scripts/repro/tsx-name-repro.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node version check
|
||||||
|
- Node 25.3.0: fails
|
||||||
|
- Node 22.22.0 (Homebrew `node@22`): fails
|
||||||
|
- Node 24: not installed here yet; needs verification
|
||||||
|
|
||||||
|
## Notes / hypothesis
|
||||||
|
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
|
||||||
|
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
|
||||||
|
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
|
||||||
|
|
||||||
|
## Regression history
|
||||||
|
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
|
||||||
|
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
|
||||||
|
|
||||||
|
## Workarounds
|
||||||
|
- Use Bun for dev scripts (current temporary revert).
|
||||||
|
- Use Node + tsc watch, then run compiled output:
|
||||||
|
```bash
|
||||||
|
pnpm exec tsc --watch --preserveWatchOutput
|
||||||
|
node --watch dist/entry.js status
|
||||||
|
```
|
||||||
|
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
|
||||||
|
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
|
||||||
|
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- https://opennext.js.org/cloudflare/howtos/keep_names
|
||||||
|
- https://esbuild.github.io/api/#keep-names
|
||||||
|
- https://github.com/evanw/esbuild/issues/1031
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
- Repro on Node 22/24 to confirm Node 25 regression.
|
||||||
|
- Test `tsx` nightly or pin to earlier version if a known regression exists.
|
||||||
|
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
|
||||||
@@ -12,6 +12,7 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
|
|||||||
|
|
||||||
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
||||||
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
||||||
|
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
|
||||||
|
|
||||||
To see the current flags/behavior, run:
|
To see the current flags/behavior, run:
|
||||||
|
|
||||||
@@ -19,6 +20,12 @@ To see the current flags/behavior, run:
|
|||||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Windows (PowerShell) help:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
|
||||||
|
```
|
||||||
|
|
||||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||||
|
|
||||||
## install.sh (recommended)
|
## install.sh (recommended)
|
||||||
@@ -73,3 +80,37 @@ Help:
|
|||||||
```bash
|
```bash
|
||||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## install.ps1 (Windows PowerShell)
|
||||||
|
|
||||||
|
What it does (high level):
|
||||||
|
|
||||||
|
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
|
||||||
|
- Choose install method:
|
||||||
|
- `npm` (default): `npm install -g clawdbot@latest`
|
||||||
|
- `git`: clone/build a source checkout and install a wrapper script
|
||||||
|
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr -useb https://clawd.bot/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `CLAWDBOT_INSTALL_METHOD=git|npm`
|
||||||
|
- `CLAWDBOT_GIT_DIR=...`
|
||||||
|
|
||||||
|
Git requirement:
|
||||||
|
|
||||||
|
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||||
|
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||||
@@ -9,8 +9,9 @@ read_when:
|
|||||||
# Gateway on macOS (external launchd)
|
# Gateway on macOS (external launchd)
|
||||||
|
|
||||||
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
||||||
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
|
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
|
||||||
service to keep the Gateway running.
|
child process, and manages a per‑user launchd service to keep the Gateway
|
||||||
|
running (or attaches to an existing local Gateway if one is already running).
|
||||||
|
|
||||||
## Install the CLI (required for local mode)
|
## Install the CLI (required for local mode)
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ Manager:
|
|||||||
Behavior:
|
Behavior:
|
||||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||||
|
- If a Gateway is already running on the configured port, the app attaches to
|
||||||
|
it instead of starting a new one.
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Gateway lifecycle on macOS
|
# Gateway lifecycle on macOS
|
||||||
|
|
||||||
The macOS app **manages the Gateway via launchd** by default. The launchd job
|
The macOS app **manages the Gateway via launchd** by default and does not spawn
|
||||||
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
|
the Gateway as a child process. It first tries to attach to an already‑running
|
||||||
auto‑start at login and restart on crashes.
|
Gateway on the configured port; if none is reachable, it enables the launchd
|
||||||
|
service via the external `clawdbot` CLI (no embedded runtime). This gives you
|
||||||
|
reliable auto‑start at login and restart on crashes.
|
||||||
|
|
||||||
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
||||||
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
|||||||
|
|
||||||
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
||||||
|
|
||||||
|
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
|
||||||
|
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
|
||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||||
|
|
||||||
## 3. Install the CLI
|
## 3. Install the CLI
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
|||||||
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||||
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
||||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||||
|
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
|
|||||||
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
||||||
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
||||||
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
||||||
|
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ad-hoc Signing Note
|
### Ad-hoc Signing Note
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ read_when:
|
|||||||
|
|
||||||
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||||
|
|
||||||
|
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||||
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
||||||
@@ -17,6 +19,19 @@ read_when:
|
|||||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||||
|
|
||||||
|
### Node service + app IPC (planned)
|
||||||
|
- A headless node service connects to the Gateway bridge.
|
||||||
|
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||||
|
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||||
|
|
||||||
|
Diagram (SCI):
|
||||||
|
```
|
||||||
|
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + TCC + system.run)
|
||||||
|
```
|
||||||
|
|
||||||
### PeekabooBridge (UI automation)
|
### PeekabooBridge (UI automation)
|
||||||
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
||||||
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
||||||
@@ -25,6 +40,7 @@ read_when:
|
|||||||
|
|
||||||
### Mach/XPC
|
### Mach/XPC
|
||||||
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||||
|
- Planned IPC keeps Unix sockets (no XPC helper).
|
||||||
|
|
||||||
## Operational flows
|
## Operational flows
|
||||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||||
@@ -38,3 +54,4 @@ read_when:
|
|||||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||||
- All communication remains local-only; no network sockets are exposed.
|
- All communication remains local-only; no network sockets are exposed.
|
||||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||||
|
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||||
+21
-5
@@ -7,8 +7,8 @@ read_when:
|
|||||||
# Clawdbot macOS Companion (menu bar + gateway broker)
|
# Clawdbot macOS Companion (menu bar + gateway broker)
|
||||||
|
|
||||||
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
||||||
manages the Gateway locally, and exposes macOS capabilities to the agent as a
|
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
||||||
node.
|
capabilities to the agent as a node.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -20,13 +20,17 @@ node.
|
|||||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||||
|
|
||||||
|
Planned:
|
||||||
|
- Run a headless **node service** locally (launchd).
|
||||||
|
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
|
||||||
|
|
||||||
## Local vs remote mode
|
## Local vs remote mode
|
||||||
|
|
||||||
- **Local** (default): the app ensures a local Gateway is running via launchd.
|
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||||
|
otherwise it enables the launchd service via `clawdbot daemon`.
|
||||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||||
a local process.
|
a local process.
|
||||||
- **Attach‑only** (debug): the app connects to an already‑running local Gateway
|
The app does not spawn the Gateway as a child process.
|
||||||
and never spawns its own.
|
|
||||||
|
|
||||||
## Launchd control
|
## Launchd control
|
||||||
|
|
||||||
@@ -54,6 +58,18 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
|
Planned split:
|
||||||
|
- Node service advertises the node surface to the Gateway.
|
||||||
|
- macOS app performs `system.run` in UI context over IPC.
|
||||||
|
|
||||||
|
Diagram (SCI):
|
||||||
|
```
|
||||||
|
Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + TCC + system.run)
|
||||||
|
```
|
||||||
|
|
||||||
## Exec approvals (system.run)
|
## Exec approvals (system.run)
|
||||||
|
|
||||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||||
|
|||||||
+2
-1
@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||||
|
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
||||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
@@ -45,7 +46,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Github Copilot
|
# Github Copilot
|
||||||
|
|
||||||
|
## What is GitHub Copilot?
|
||||||
|
|
||||||
|
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
||||||
|
models for your GitHub account and plan. Clawdbot can use Copilot as a model
|
||||||
|
provider in two different ways.
|
||||||
|
|
||||||
|
## Two ways to use Copilot in Clawdbot
|
||||||
|
|
||||||
|
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||||
|
|
||||||
|
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||||
|
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||||
|
because it does not require VS Code.
|
||||||
|
|
||||||
|
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||||
|
|
||||||
|
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
|
||||||
|
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
|
||||||
|
this when you already run Copilot Proxy in VS Code or need to route through it.
|
||||||
|
You must enable the plugin and keep the VS Code extension running.
|
||||||
|
|
||||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||||
profile.
|
profile.
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ read_when:
|
|||||||
- **Node identity:** use existing `nodeId`.
|
- **Node identity:** use existing `nodeId`.
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||||
|
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
|
||||||
|
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
### Host
|
### Host
|
||||||
@@ -139,19 +141,30 @@ Notes:
|
|||||||
|
|
||||||
## UI integration (macOS app)
|
## UI integration (macOS app)
|
||||||
### IPC
|
### IPC
|
||||||
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
|
- Unix socket at `~/.clawdbot/exec-approvals.sock` (0600).
|
||||||
- Runner connects and sends an approval request; UI responds with a decision.
|
- Token stored in `exec-approvals.json` (0600).
|
||||||
- Token stored in `exec-approvals.json`.
|
- Peer checks: same-UID only.
|
||||||
|
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
|
||||||
|
- Short TTL (e.g., 10s) + max payload + rate limit.
|
||||||
|
|
||||||
### Ask flow
|
### Ask flow (macOS app exec host)
|
||||||
1) Runner receives `system.run` from gateway.
|
1) Node service receives `system.run` from gateway.
|
||||||
2) If ask required, runner connects to the socket and sends a prompt request.
|
2) Node service connects to the local socket and sends the prompt/exec request.
|
||||||
3) UI shows dialog; returns decision.
|
3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
|
||||||
4) Runner enforces decision and proceeds.
|
4) App executes the command in UI context and returns output.
|
||||||
|
5) Node service returns output to gateway.
|
||||||
|
|
||||||
If UI missing:
|
If UI missing:
|
||||||
- Apply `askFallback` (`deny|allowlist|full`).
|
- Apply `askFallback` (`deny|allowlist|full`).
|
||||||
|
|
||||||
|
### Diagram (SCI)
|
||||||
|
```
|
||||||
|
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + TCC + system.run)
|
||||||
|
```
|
||||||
|
|
||||||
## Node identity + binding
|
## Node identity + binding
|
||||||
- Use existing `nodeId` from Bridge pairing.
|
- Use existing `nodeId` from Bridge pairing.
|
||||||
- Binding model:
|
- Binding model:
|
||||||
|
|||||||
+22
-36
@@ -60,7 +60,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||||
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
|
|
||||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||||
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||||
@@ -406,7 +405,7 @@ You have three supported patterns:
|
|||||||
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
||||||
|
|
||||||
**Option B - use a macOS node (no SSH).**
|
**Option B - use a macOS node (no SSH).**
|
||||||
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
|
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
|
||||||
|
|
||||||
**Option C - proxy macOS binaries over SSH (advanced).**
|
**Option C - proxy macOS binaries over SSH (advanced).**
|
||||||
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
||||||
@@ -502,14 +501,23 @@ is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
|
|||||||
|
|
||||||
### Does semantic memory search require an OpenAI API key?
|
### Does semantic memory search require an OpenAI API key?
|
||||||
|
|
||||||
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
|
Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and
|
||||||
chat/completions and does **not** grant embeddings access, so **signing in with
|
does **not** grant embeddings access, so **signing in with Codex (OAuth or the
|
||||||
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
|
Codex CLI login)** does not help for semantic memory search. OpenAI embeddings
|
||||||
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
|
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
|
||||||
`models.providers.openai.apiKey`). If you’d rather stay local, set
|
|
||||||
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
|
If you don’t set a provider explicitly, Clawdbot auto-selects a provider when it
|
||||||
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
|
can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars).
|
||||||
for the setup details.
|
It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
|
||||||
|
resolves. If neither key is available, memory search stays disabled until you
|
||||||
|
configure it. If you have a local model path configured and present, Clawdbot
|
||||||
|
prefers `local`.
|
||||||
|
|
||||||
|
If you’d rather stay local, set `memorySearch.provider = "local"` (and optionally
|
||||||
|
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||||
|
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||||
|
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
|
||||||
|
models — see [Memory](/concepts/memory) for the setup details.
|
||||||
|
|
||||||
## Where things live on disk
|
## Where things live on disk
|
||||||
|
|
||||||
@@ -743,23 +751,6 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
|||||||
|
|
||||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||||
|
|
||||||
### Can I run a headless node host without the macOS app?
|
|
||||||
|
|
||||||
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
|
|
||||||
without any UI. It has no screen/camera/notify support (use the macOS app for those).
|
|
||||||
|
|
||||||
Start it:
|
|
||||||
```bash
|
|
||||||
clawdbot node start --host <gateway-host> --port 18790
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
|
|
||||||
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
|
|
||||||
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
|
|
||||||
|
|
||||||
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
|
|
||||||
|
|
||||||
### Is there an API / RPC way to apply config?
|
### Is there an API / RPC way to apply config?
|
||||||
|
|
||||||
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
||||||
@@ -898,19 +889,14 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
|||||||
|
|
||||||
### Do sessions reset automatically if I never send `/new`?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
|
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||||
You can also add an idle window; when both daily and idle resets are configured,
|
message starts a fresh session id for that chat key. This does not delete
|
||||||
whichever expires first starts a new session id on the next message. This does
|
transcripts — it just starts a new session.
|
||||||
not delete transcripts — it just starts a new session.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
reset: {
|
idleMinutes: 240
|
||||||
mode: "daily",
|
|
||||||
atHour: 4,
|
|
||||||
idleMinutes: 240
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ Exec approvals are enforced locally on the execution host:
|
|||||||
- **gateway host** → `clawdbot` process on the gateway machine
|
- **gateway host** → `clawdbot` process on the gateway machine
|
||||||
- **node host** → node runner (macOS companion app or headless node host)
|
- **node host** → node runner (macOS companion app or headless node host)
|
||||||
|
|
||||||
|
Planned macOS split:
|
||||||
|
- **node service** forwards `system.run` to the **macOS app** over local IPC.
|
||||||
|
- **macOS app** enforces approvals + executes the command in UI context.
|
||||||
|
|
||||||
## Settings and storage
|
## Settings and storage
|
||||||
|
|
||||||
Approvals live in a local JSON file on the execution host:
|
Approvals live in a local JSON file on the execution host:
|
||||||
@@ -128,6 +132,19 @@ Actions:
|
|||||||
- **Always allow** → add to allowlist + run
|
- **Always allow** → add to allowlist + run
|
||||||
- **Deny** → block
|
- **Deny** → block
|
||||||
|
|
||||||
|
### macOS IPC flow (planned)
|
||||||
|
```
|
||||||
|
Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + approvals + system.run)
|
||||||
|
```
|
||||||
|
|
||||||
|
Security notes:
|
||||||
|
- Unix socket mode `0600`, token stored in `exec-approvals.json`.
|
||||||
|
- Same-UID peer check.
|
||||||
|
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
|
||||||
|
|
||||||
## System events
|
## System events
|
||||||
|
|
||||||
Exec lifecycle is surfaced as system messages:
|
Exec lifecycle is surfaced as system messages:
|
||||||
|
|||||||
@@ -836,10 +836,20 @@ async function processMessage(
|
|||||||
const fromLabel = message.isGroup
|
const fromLabel = message.isGroup
|
||||||
? `group:${peerId}`
|
? `group:${peerId}`
|
||||||
: message.senderName || `user:${message.senderId}`;
|
: message.senderName || `user:${message.senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "BlueBubbles",
|
channel: "BlueBubbles",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
let chatGuidForActions = chatGuid;
|
let chatGuidForActions = chatGuid;
|
||||||
|
|||||||
@@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
isThreadRoot: event.isThreadRoot,
|
isThreadRoot: event.isThreadRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "matrix",
|
||||||
|
peer: {
|
||||||
|
kind: isDirectMessage ? "dm" : "channel",
|
||||||
|
id: isDirectMessage ? senderId : roomId,
|
||||||
|
},
|
||||||
|
});
|
||||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: event.getTs() ?? undefined,
|
timestamp: event.getTs() ?? undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||||
cfg,
|
|
||||||
channel: "matrix",
|
|
||||||
peer: {
|
|
||||||
kind: isDirectMessage ? "dm" : "channel",
|
|
||||||
id: isDirectMessage ? senderId : roomId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: body,
|
Body: body,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
@@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
OriginatingTo: `room:${roomId}`,
|
OriginatingTo: `room:${roomId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
agentId: route.agentId,
|
storePath,
|
||||||
});
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
ctx: ctxPayload,
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
||||||
ctx: ctxPayload,
|
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ describeWithKey("memory plugin e2e", () => {
|
|||||||
// Dynamic import to avoid loading LanceDB when not testing
|
// Dynamic import to avoid loading LanceDB when not testing
|
||||||
const { default: memoryPlugin } = await import("./index.js");
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
expect(memoryPlugin.id).toBe("memory");
|
expect(memoryPlugin.id).toBe("memory-lancedb");
|
||||||
expect(memoryPlugin.name).toBe("Memory (Vector)");
|
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
|
||||||
expect(memoryPlugin.kind).toBe("memory");
|
expect(memoryPlugin.kind).toBe("memory");
|
||||||
expect(memoryPlugin.configSchema).toBeDefined();
|
expect(memoryPlugin.configSchema).toBeDefined();
|
||||||
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
||||||
@@ -185,8 +185,8 @@ describeWithKey("memory plugin live tests", () => {
|
|||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
id: "memory",
|
id: "memory-lancedb",
|
||||||
name: "Memory (Vector)",
|
name: "Memory (LanceDB)",
|
||||||
source: "test",
|
source: "test",
|
||||||
config: {},
|
config: {},
|
||||||
pluginConfig: {
|
pluginConfig: {
|
||||||
|
|||||||
+15
-13
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Clawdbot Memory Plugin
|
* Clawdbot Memory (LanceDB) Plugin
|
||||||
*
|
*
|
||||||
* Long-term memory with vector search for AI conversations.
|
* Long-term memory with vector search for AI conversations.
|
||||||
* Uses LanceDB for storage and OpenAI for embeddings.
|
* Uses LanceDB for storage and OpenAI for embeddings.
|
||||||
@@ -214,9 +214,9 @@ function detectCategory(text: string): MemoryCategory {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const memoryPlugin = {
|
const memoryPlugin = {
|
||||||
id: "memory",
|
id: "memory-lancedb",
|
||||||
name: "Memory (Vector)",
|
name: "Memory (LanceDB)",
|
||||||
description: "Long-term memory with vector search and seamless auto-recall/capture",
|
description: "LanceDB-backed long-term memory with auto-recall/capture",
|
||||||
kind: "memory" as const,
|
kind: "memory" as const,
|
||||||
configSchema: memoryConfigSchema,
|
configSchema: memoryConfigSchema,
|
||||||
|
|
||||||
@@ -227,7 +227,9 @@ const memoryPlugin = {
|
|||||||
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||||
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||||
|
|
||||||
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
|
api.logger.info(
|
||||||
|
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
|
||||||
|
);
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Tools
|
// Tools
|
||||||
@@ -417,7 +419,7 @@ const memoryPlugin = {
|
|||||||
({ program }) => {
|
({ program }) => {
|
||||||
const memory = program
|
const memory = program
|
||||||
.command("ltm")
|
.command("ltm")
|
||||||
.description("Long-term memory plugin commands");
|
.description("LanceDB memory plugin commands");
|
||||||
|
|
||||||
memory
|
memory
|
||||||
.command("list")
|
.command("list")
|
||||||
@@ -477,14 +479,14 @@ const memoryPlugin = {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
api.logger.info?.(
|
api.logger.info?.(
|
||||||
`memory: injecting ${results.length} memories into context`,
|
`memory-lancedb: injecting ${results.length} memories into context`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`memory: recall failed: ${String(err)}`);
|
api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -559,10 +561,10 @@ const memoryPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stored > 0) {
|
if (stored > 0) {
|
||||||
api.logger.info(`memory: auto-captured ${stored} memories`);
|
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`memory: capture failed: ${String(err)}`);
|
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -572,14 +574,14 @@ const memoryPlugin = {
|
|||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
api.registerService({
|
api.registerService({
|
||||||
id: "memory",
|
id: "memory-lancedb",
|
||||||
start: () => {
|
start: () => {
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
api.logger.info("memory: stopped");
|
api.logger.info("memory-lancedb: stopped");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory",
|
"name": "@clawdbot/memory-lancedb",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lancedb/lancedb": "^0.15.0",
|
"@lancedb/lancedb": "^0.15.0",
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
|
|||||||
@@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
@@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
historyKey,
|
historyKey,
|
||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
core.channel.reply.formatAgentEnvelope({
|
core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: conversationType,
|
from: conversationType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||||
}),
|
envelope: envelopeOptions,
|
||||||
});
|
}),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
@@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
...mediaPayload,
|
...mediaPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
agentId: route.agentId,
|
storePath,
|
||||||
});
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
|||||||
@@ -62,6 +62,34 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
/** Map of call SID to stream SID for media streams */
|
/** Map of call SID to stream SID for media streams */
|
||||||
private callStreamMap = new Map<string, string>();
|
private callStreamMap = new Map<string, string>();
|
||||||
|
|
||||||
|
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||||
|
private readonly twimlStorage = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stored TwiML for a given `callId`.
|
||||||
|
*
|
||||||
|
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
||||||
|
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
||||||
|
*/
|
||||||
|
private deleteStoredTwiml(callId: string): void {
|
||||||
|
this.twimlStorage.delete(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
||||||
|
*
|
||||||
|
* This is used when we only have `providerCallId` (e.g. hangup).
|
||||||
|
*/
|
||||||
|
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
|
||||||
|
const webhookUrl = this.callWebhookUrls.get(providerCallId);
|
||||||
|
if (!webhookUrl) return;
|
||||||
|
|
||||||
|
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
||||||
|
if (!callIdMatch) return;
|
||||||
|
|
||||||
|
this.deleteStoredTwiml(callIdMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||||
if (!config.accountSid) {
|
if (!config.accountSid) {
|
||||||
throw new Error("Twilio Account SID is required");
|
throw new Error("Twilio Account SID is required");
|
||||||
@@ -228,8 +256,14 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
case "busy":
|
case "busy":
|
||||||
case "no-answer":
|
case "no-answer":
|
||||||
case "failed":
|
case "failed":
|
||||||
|
if (callIdOverride) {
|
||||||
|
this.deleteStoredTwiml(callIdOverride);
|
||||||
|
}
|
||||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||||
case "canceled":
|
case "canceled":
|
||||||
|
if (callIdOverride) {
|
||||||
|
this.deleteStoredTwiml(callIdOverride);
|
||||||
|
}
|
||||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -254,11 +288,25 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const params = new URLSearchParams(ctx.rawBody);
|
const params = new URLSearchParams(ctx.rawBody);
|
||||||
const callStatus = params.get("CallStatus");
|
const callStatus = params.get("CallStatus");
|
||||||
const direction = params.get("Direction");
|
const direction = params.get("Direction");
|
||||||
|
const callIdFromQuery =
|
||||||
|
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||||
|
? ctx.query.callId.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
console.log(
|
// Avoid logging webhook params/TwiML (may contain PII).
|
||||||
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Handle initial TwiML request (when Twilio first initiates the call)
|
||||||
|
// Check if we have stored TwiML for this call (notify mode)
|
||||||
|
if (callIdFromQuery) {
|
||||||
|
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
||||||
|
if (storedTwiml) {
|
||||||
|
// Clean up after serving (one-time use)
|
||||||
|
this.deleteStoredTwiml(callIdFromQuery);
|
||||||
|
return storedTwiml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle subsequent webhook requests (status callbacks, etc.)
|
||||||
// For inbound calls, answer immediately with stream
|
// For inbound calls, answer immediately with stream
|
||||||
if (direction === "inbound") {
|
if (direction === "inbound") {
|
||||||
const streamUrl = this.getStreamUrl();
|
const streamUrl = this.getStreamUrl();
|
||||||
@@ -328,22 +376,28 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const url = new URL(input.webhookUrl);
|
const url = new URL(input.webhookUrl);
|
||||||
url.searchParams.set("callId", input.callId);
|
url.searchParams.set("callId", input.callId);
|
||||||
|
|
||||||
// Build request params
|
// Create separate URL for status callbacks (required by Twilio)
|
||||||
|
const statusUrl = new URL(input.webhookUrl);
|
||||||
|
statusUrl.searchParams.set("callId", input.callId);
|
||||||
|
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
|
||||||
|
|
||||||
|
// Store TwiML content if provided (for notify mode)
|
||||||
|
// We now serve it from the webhook endpoint instead of sending inline
|
||||||
|
if (input.inlineTwiml) {
|
||||||
|
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request params - always use URL-based TwiML.
|
||||||
|
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
To: input.to,
|
To: input.to,
|
||||||
From: input.from,
|
From: input.from,
|
||||||
StatusCallback: url.toString(),
|
Url: url.toString(), // TwiML serving endpoint
|
||||||
|
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
||||||
StatusCallbackEvent: "initiated ringing answered completed",
|
StatusCallbackEvent: "initiated ringing answered completed",
|
||||||
Timeout: "30",
|
Timeout: "30",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use inline TwiML for notify mode (simpler, no webhook needed)
|
|
||||||
if (input.inlineTwiml) {
|
|
||||||
params.Twiml = input.inlineTwiml;
|
|
||||||
} else {
|
|
||||||
params.Url = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.apiRequest<TwilioCallResponse>(
|
const result = await this.apiRequest<TwilioCallResponse>(
|
||||||
"/Calls.json",
|
"/Calls.json",
|
||||||
params,
|
params,
|
||||||
@@ -361,6 +415,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Hang up a call via Twilio API.
|
* Hang up a call via Twilio API.
|
||||||
*/
|
*/
|
||||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||||
|
this.deleteStoredTwimlForProviderCall(input.providerCallId);
|
||||||
|
|
||||||
this.callWebhookUrls.delete(input.providerCallId);
|
this.callWebhookUrls.delete(input.providerCallId);
|
||||||
|
|
||||||
await this.apiRequest(
|
await this.apiRequest(
|
||||||
|
|||||||
@@ -530,10 +530,20 @@ async function processMessageWithPipeline(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Zalo",
|
channel: "Zalo",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: date ? date * 1000 : undefined,
|
timestamp: date ? date * 1000 : undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -560,9 +570,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
OriginatingTo: `zalo:${chatId}`,
|
OriginatingTo: `zalo:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
@@ -274,10 +274,20 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Zalo Personal",
|
channel: "Zalo Personal",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,9 +311,6 @@ async function processMessage(
|
|||||||
OriginatingTo: `zalouser:${chatId}`,
|
OriginatingTo: `zalouser:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
+18
-18
@@ -65,27 +65,27 @@
|
|||||||
"dist/whatsapp/**"
|
"dist/whatsapp/**"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/entry.ts",
|
"dev": "node scripts/run-node.mjs",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"prepack": "pnpm build",
|
"prepack": "pnpm build",
|
||||||
"docs:list": "tsx scripts/docs-list.ts",
|
"docs:list": "node scripts/docs-list.js",
|
||||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
|
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||||
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
|
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||||
"release:check": "tsx scripts/release-check.ts",
|
"release:check": "node --import tsx scripts/release-check.ts",
|
||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
"ui:dev": "node scripts/ui.js dev",
|
"ui:dev": "node scripts/ui.js dev",
|
||||||
"ui:build": "node scripts/ui.js build",
|
"ui:build": "node scripts/ui.js build",
|
||||||
"start": "tsx src/entry.ts",
|
"start": "node scripts/run-node.mjs",
|
||||||
"clawdbot": "tsx src/entry.ts",
|
"clawdbot": "node scripts/run-node.mjs",
|
||||||
"gateway:watch": "tsx watch src/entry.ts gateway --force",
|
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
||||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway",
|
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
|
||||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway --reset",
|
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
|
||||||
"tui": "tsx src/entry.ts tui",
|
"tui": "node scripts/run-node.mjs tui",
|
||||||
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
|
"tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui",
|
||||||
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
|
"clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||||
"ios:gen": "cd apps/ios && xcodegen generate",
|
"ios:gen": "cd apps/ios && xcodegen generate",
|
||||||
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
||||||
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "pnpm --dir ui test",
|
"test:ui": "pnpm --dir ui test",
|
||||||
"test:force": "tsx scripts/test-force.ts",
|
"test:force": "node --import tsx scripts/test-force.ts",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
||||||
@@ -126,11 +126,11 @@
|
|||||||
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
||||||
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||||
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const binDir = path.join(root, "bin");
|
||||||
|
const scriptPath = path.join(root, "scripts", "docs-list.js");
|
||||||
|
const binPath = path.join(binDir, "docs-list");
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
|
||||||
|
|
||||||
|
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });
|
||||||
@@ -4,11 +4,28 @@ set -euo pipefail
|
|||||||
APP_BUNDLE="${1:-dist/Clawdbot.app}"
|
APP_BUNDLE="${1:-dist/Clawdbot.app}"
|
||||||
IDENTITY="${SIGN_IDENTITY:-}"
|
IDENTITY="${SIGN_IDENTITY:-}"
|
||||||
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
||||||
|
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
|
||||||
|
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
|
||||||
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
|
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
|
||||||
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
|
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
|
||||||
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
|
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
|
||||||
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
||||||
|
|
||||||
|
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
|
||||||
|
cat <<'HELP'
|
||||||
|
Usage: scripts/codesign-mac-app.sh [app-bundle]
|
||||||
|
|
||||||
|
Env:
|
||||||
|
SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
|
||||||
|
ALLOW_ADHOC_SIGNING=1
|
||||||
|
CODESIGN_TIMESTAMP=auto|on|off
|
||||||
|
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
|
||||||
|
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
|
||||||
|
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
|
||||||
|
HELP
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -d "$APP_BUNDLE" ]; then
|
if [ ! -d "$APP_BUNDLE" ]; then
|
||||||
echo "App bundle not found: $APP_BUNDLE" >&2
|
echo "App bundle not found: $APP_BUNDLE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -184,6 +201,14 @@ cat > "$ENT_TMP_APP" <<'PLIST'
|
|||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|
||||||
|
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
|
||||||
|
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
|
||||||
|
fi
|
||||||
|
|
||||||
# The time-sensitive entitlement is restricted and requires explicit enablement
|
# The time-sensitive entitlement is restricted and requires explicit enablement
|
||||||
# (and typically a matching provisioning profile). It is *not* safe to enable
|
# (and typically a matching provisioning profile). It is *not* safe to enable
|
||||||
# unconditionally for local debug packaging since AMFI will refuse to launch.
|
# unconditionally for local debug packaging since AMFI will refuse to launch.
|
||||||
@@ -209,6 +234,51 @@ sign_plain_item() {
|
|||||||
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
|
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
team_id_for() {
|
||||||
|
codesign -dv --verbose=4 "$1" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}'
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_team_ids() {
|
||||||
|
if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then
|
||||||
|
echo "Note: skipping Team ID audit (SKIP_TEAM_ID_CHECK=1)."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local expected
|
||||||
|
expected="$(team_id_for "$APP_BUNDLE" || true)"
|
||||||
|
if [[ -z "$expected" ]]; then
|
||||||
|
echo "WARN: TeamIdentifier missing on app bundle; skipping Team ID audit."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local mismatches=()
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
|
||||||
|
local team
|
||||||
|
team="$(team_id_for "$f" || true)"
|
||||||
|
if [[ -z "$team" ]]; then
|
||||||
|
team="not set"
|
||||||
|
fi
|
||||||
|
if [[ "$expected" == "not set" ]]; then
|
||||||
|
if [[ "$team" != "not set" ]]; then
|
||||||
|
mismatches+=("$f (TeamIdentifier=$team)")
|
||||||
|
fi
|
||||||
|
elif [[ "$team" != "$expected" ]]; then
|
||||||
|
mismatches+=("$f (TeamIdentifier=$team)")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(find "$APP_BUNDLE" -type f -print0)
|
||||||
|
|
||||||
|
if [[ "${#mismatches[@]}" -gt 0 ]]; then
|
||||||
|
echo "ERROR: Team ID mismatch detected (expected: $expected)"
|
||||||
|
for entry in "${mismatches[@]}"; do
|
||||||
|
echo " - $entry"
|
||||||
|
done
|
||||||
|
echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Sign main binary
|
# Sign main binary
|
||||||
if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
|
if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
|
||||||
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
|
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
|
||||||
@@ -218,6 +288,11 @@ fi
|
|||||||
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
|
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
|
||||||
if [ -d "$SPARKLE" ]; then
|
if [ -d "$SPARKLE" ]; then
|
||||||
echo "Signing Sparkle framework and helpers"
|
echo "Signing Sparkle framework and helpers"
|
||||||
|
find "$SPARKLE" -type f -print0 | while IFS= read -r -d '' f; do
|
||||||
|
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
|
||||||
|
sign_plain_item "$f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
sign_plain_item "$SPARKLE/Versions/B/Sparkle"
|
sign_plain_item "$SPARKLE/Versions/B/Sparkle"
|
||||||
sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
|
sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
|
||||||
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
|
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
|
||||||
@@ -240,5 +315,7 @@ fi
|
|||||||
# Finally sign the bundle
|
# Finally sign the bundle
|
||||||
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
||||||
|
|
||||||
|
verify_team_ids
|
||||||
|
|
||||||
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
|
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
|
||||||
echo "Codesign complete for $APP_BUNDLE"
|
echo "Codesign complete for $APP_BUNDLE"
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
|
|
||||||
process.stdout.on('error', (error) => {
|
process.stdout.on('error', (error) => {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
|
if (error?.code === 'EPIPE') {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -14,8 +14,12 @@ const DOCS_DIR = join(process.cwd(), 'docs');
|
|||||||
|
|
||||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
||||||
|
|
||||||
function compactStrings(values: unknown[]): string[] {
|
/**
|
||||||
const result: string[] = [];
|
* @param {unknown[]} values
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function compactStrings(values) {
|
||||||
|
const result = [];
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
continue;
|
continue;
|
||||||
@@ -28,9 +32,14 @@ function compactStrings(values: unknown[]): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkMarkdownFiles(dir: string, base: string = dir): string[] {
|
/**
|
||||||
|
* @param {string} dir
|
||||||
|
* @param {string} base
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function walkMarkdownFiles(dir, base = dir) {
|
||||||
const entries = readdirSync(dir, { withFileTypes: true });
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
const files: string[] = [];
|
const files = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.name.startsWith('.')) {
|
if (entry.name.startsWith('.')) {
|
||||||
continue;
|
continue;
|
||||||
@@ -48,11 +57,11 @@ function walkMarkdownFiles(dir: string, base: string = dir): string[] {
|
|||||||
return files.sort((a, b) => a.localeCompare(b));
|
return files.sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMetadata(fullPath: string): {
|
/**
|
||||||
summary: string | null;
|
* @param {string} fullPath
|
||||||
readWhen: string[];
|
* @returns {{ summary: string | null; readWhen: string[]; error?: string }}
|
||||||
error?: string;
|
*/
|
||||||
} {
|
function extractMetadata(fullPath) {
|
||||||
const content = readFileSync(fullPath, 'utf8');
|
const content = readFileSync(fullPath, 'utf8');
|
||||||
|
|
||||||
if (!content.startsWith('---')) {
|
if (!content.startsWith('---')) {
|
||||||
@@ -67,9 +76,9 @@ function extractMetadata(fullPath: string): {
|
|||||||
const frontMatter = content.slice(3, endIndex).trim();
|
const frontMatter = content.slice(3, endIndex).trim();
|
||||||
const lines = frontMatter.split('\n');
|
const lines = frontMatter.split('\n');
|
||||||
|
|
||||||
let summaryLine: string | null = null;
|
let summaryLine = null;
|
||||||
const readWhen: string[] = [];
|
const readWhen = [];
|
||||||
let collectingField: 'read_when' | null = null;
|
let collectingField = null;
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
@@ -85,7 +94,7 @@ function extractMetadata(fullPath: string): {
|
|||||||
const inline = line.slice('read_when:'.length).trim();
|
const inline = line.slice('read_when:'.length).trim();
|
||||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown;
|
const parsed = JSON.parse(inline.replace(/'/g, '"'));
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
readWhen.push(...compactStrings(parsed));
|
readWhen.push(...compactStrings(parsed));
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env -S node --import tsx
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import "../../src/logging/subsystem.js";
|
||||||
|
|
||||||
|
console.log("tsx-name-repro: loaded logging/subsystem");
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const env = { ...process.env };
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
build.on("exit", (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code !== 0 && code !== null) {
|
||||||
|
process.exit(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeProcess = spawn(process.execPath, ["dist/entry.js", ...args], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeProcess.on("exit", (exitCode, exitSignal) => {
|
||||||
|
if (exitSignal) {
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(exitCode ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env -S node --import tsx
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|||||||
+12
-41
@@ -45,19 +45,8 @@ function which(cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRunner() {
|
function resolveRunner() {
|
||||||
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
|
|
||||||
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
|
|
||||||
if (!preferPnpm) {
|
|
||||||
const bun = which("bun");
|
|
||||||
if (bun) return { cmd: bun, kind: "bun" };
|
|
||||||
}
|
|
||||||
const pnpm = which("pnpm");
|
const pnpm = which("pnpm");
|
||||||
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
||||||
if (preferPnpm) {
|
|
||||||
// Fallback to bun if pnpm not found even when preferring pnpm
|
|
||||||
const bun = which("bun");
|
|
||||||
if (bun) return { cmd: bun, kind: "bun" };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +96,7 @@ if (!action) {
|
|||||||
|
|
||||||
const runner = resolveRunner();
|
const runner = resolveRunner();
|
||||||
if (!runner) {
|
if (!runner) {
|
||||||
process.stderr.write(
|
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
|
||||||
"Missing UI runner: install bun or pnpm, then retry.\n",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,32 +116,16 @@ if (action !== "install" && !script) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runner.kind === "bun") {
|
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
else {
|
||||||
else {
|
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
const installEnv =
|
||||||
const installEnv =
|
action === "build"
|
||||||
action === "build"
|
? { ...process.env, NODE_ENV: "production" }
|
||||||
? { ...process.env, NODE_ENV: "production" }
|
: process.env;
|
||||||
: process.env;
|
const installArgs =
|
||||||
const installArgs =
|
action === "build" ? ["install", "--prod"] : ["install"];
|
||||||
action === "build" ? ["install", "--production"] : ["install"];
|
runSync(runner.cmd, installArgs, installEnv);
|
||||||
runSync(runner.cmd, installArgs, installEnv);
|
|
||||||
}
|
|
||||||
run(runner.cmd, ["run", script, ...rest]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
|
||||||
else {
|
|
||||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
|
||||||
const installEnv =
|
|
||||||
action === "build"
|
|
||||||
? { ...process.env, NODE_ENV: "production" }
|
|
||||||
: process.env;
|
|
||||||
const installArgs =
|
|
||||||
action === "build" ? ["install", "--prod"] : ["install"];
|
|
||||||
runSync(runner.cmd, installArgs, installEnv);
|
|
||||||
}
|
|
||||||
run(runner.cmd, ["run", script, ...rest]);
|
|
||||||
}
|
}
|
||||||
|
run(runner.cmd, ["run", script, ...rest]);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const env = { ...process.env };
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialBuild.status !== 0) {
|
||||||
|
process.exit(initialBuild.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeProcess = spawn(process.execPath, ["--watch", "dist/entry.js", ...args], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
let exiting = false;
|
||||||
|
|
||||||
|
function cleanup(code = 0) {
|
||||||
|
if (exiting) return;
|
||||||
|
exiting = true;
|
||||||
|
nodeProcess.kill("SIGTERM");
|
||||||
|
tsc.kill("SIGTERM");
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => cleanup(130));
|
||||||
|
process.on("SIGTERM", () => cleanup(143));
|
||||||
|
|
||||||
|
tsc.on("exit", (code) => {
|
||||||
|
if (exiting) return;
|
||||||
|
cleanup(code ?? 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeProcess.on("exit", (code, signal) => {
|
||||||
|
if (signal || exiting) return;
|
||||||
|
cleanup(code ?? 1);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSubsystemLogger } from "../../logging.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
export const AUTH_STORE_VERSION = 1;
|
export const AUTH_STORE_VERSION = 1;
|
||||||
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||||
|
|||||||
@@ -4,22 +4,20 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const execSyncMock = vi.hoisted(() => vi.fn());
|
const execSyncMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("node:child_process", () => ({
|
|
||||||
execSync: execSyncMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("cli credentials", () => {
|
describe("cli credentials", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
execSyncMock.mockReset();
|
execSyncMock.mockReset();
|
||||||
delete process.env.CODEX_HOME;
|
delete process.env.CODEX_HOME;
|
||||||
|
const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js");
|
||||||
|
resetCliCredentialCachesForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the Claude Code keychain item in place", async () => {
|
it("updates the Claude Code keychain item in place", async () => {
|
||||||
@@ -44,11 +42,14 @@ describe("cli credentials", () => {
|
|||||||
|
|
||||||
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
||||||
|
|
||||||
const ok = writeClaudeCliKeychainCredentials({
|
const ok = writeClaudeCliKeychainCredentials(
|
||||||
access: "new-access",
|
{
|
||||||
refresh: "new-refresh",
|
access: "new-access",
|
||||||
expires: Date.now() + 60_000,
|
refresh: "new-refresh",
|
||||||
});
|
expires: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
{ execSync: execSyncMock },
|
||||||
|
);
|
||||||
|
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false);
|
expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false);
|
||||||
@@ -130,11 +131,13 @@ describe("cli credentials", () => {
|
|||||||
allowKeychainPrompt: true,
|
allowKeychainPrompt: true,
|
||||||
ttlMs: 15 * 60 * 1000,
|
ttlMs: 15 * 60 * 1000,
|
||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
|
execSync: execSyncMock,
|
||||||
});
|
});
|
||||||
const second = readClaudeCliCredentialsCached({
|
const second = readClaudeCliCredentialsCached({
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
ttlMs: 15 * 60 * 1000,
|
ttlMs: 15 * 60 * 1000,
|
||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
|
execSync: execSyncMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first).toBeTruthy();
|
expect(first).toBeTruthy();
|
||||||
@@ -161,6 +164,7 @@ describe("cli credentials", () => {
|
|||||||
allowKeychainPrompt: true,
|
allowKeychainPrompt: true,
|
||||||
ttlMs: 15 * 60 * 1000,
|
ttlMs: 15 * 60 * 1000,
|
||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
|
execSync: execSyncMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
|
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
|
||||||
@@ -169,6 +173,7 @@ describe("cli credentials", () => {
|
|||||||
allowKeychainPrompt: true,
|
allowKeychainPrompt: true,
|
||||||
ttlMs: 15 * 60 * 1000,
|
ttlMs: 15 * 60 * 1000,
|
||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
|
execSync: execSyncMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first).toBeTruthy();
|
expect(first).toBeTruthy();
|
||||||
@@ -196,7 +201,7 @@ describe("cli credentials", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
||||||
const creds = readCodexCliCredentials({ platform: "darwin" });
|
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
|
||||||
|
|
||||||
expect(creds).toMatchObject({
|
expect(creds).toMatchObject({
|
||||||
access: "keychain-access",
|
access: "keychain-access",
|
||||||
@@ -226,7 +231,7 @@ describe("cli credentials", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
||||||
const creds = readCodexCliCredentials();
|
const creds = readCodexCliCredentials({ execSync: execSyncMock });
|
||||||
|
|
||||||
expect(creds).toMatchObject({
|
expect(creds).toMatchObject({
|
||||||
access: "file-access",
|
access: "file-access",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import path from "node:path";
|
|||||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/auth-profiles");
|
const log = createSubsystemLogger("agents/auth-profiles");
|
||||||
@@ -28,6 +28,12 @@ let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
|||||||
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
||||||
let qwenCliCache: CachedValue<QwenCliCredential> | null = null;
|
let qwenCliCache: CachedValue<QwenCliCredential> | null = null;
|
||||||
|
|
||||||
|
export function resetCliCredentialCachesForTest(): void {
|
||||||
|
claudeCliCache = null;
|
||||||
|
codexCliCache = null;
|
||||||
|
qwenCliCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClaudeCliCredential =
|
export type ClaudeCliCredential =
|
||||||
| {
|
| {
|
||||||
type: "oauth";
|
type: "oauth";
|
||||||
@@ -69,6 +75,8 @@ type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
|||||||
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExecSyncFn = typeof execSync;
|
||||||
|
|
||||||
function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
||||||
const baseDir = homeDir ?? resolveUserPath("~");
|
const baseDir = homeDir ?? resolveUserPath("~");
|
||||||
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
|
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||||
@@ -100,19 +108,24 @@ function computeCodexKeychainAccount(codexHome: string) {
|
|||||||
|
|
||||||
function readCodexKeychainCredentials(options?: {
|
function readCodexKeychainCredentials(options?: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
|
execSync?: ExecSyncFn;
|
||||||
}): CodexCliCredential | null {
|
}): CodexCliCredential | null {
|
||||||
const platform = options?.platform ?? process.platform;
|
const platform = options?.platform ?? process.platform;
|
||||||
if (platform !== "darwin") return null;
|
if (platform !== "darwin") return null;
|
||||||
|
const execSyncImpl = options?.execSync ?? execSync;
|
||||||
|
|
||||||
const codexHome = resolveCodexHomePath();
|
const codexHome = resolveCodexHomePath();
|
||||||
const account = computeCodexKeychainAccount(codexHome);
|
const account = computeCodexKeychainAccount(codexHome);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const secret = execSync(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, {
|
const secret = execSyncImpl(
|
||||||
encoding: "utf8",
|
`security find-generic-password -s "Codex Auth" -a "${account}" -w`,
|
||||||
timeout: 5000,
|
{
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
encoding: "utf8",
|
||||||
}).trim();
|
timeout: 5000,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
).trim();
|
||||||
|
|
||||||
const parsed = JSON.parse(secret) as Record<string, unknown>;
|
const parsed = JSON.parse(secret) as Record<string, unknown>;
|
||||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||||
@@ -170,9 +183,11 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
|
function readClaudeCliKeychainCredentials(
|
||||||
|
execSyncImpl: ExecSyncFn = execSync,
|
||||||
|
): ClaudeCliCredential | null {
|
||||||
try {
|
try {
|
||||||
const result = execSync(
|
const result = execSyncImpl(
|
||||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
@@ -213,10 +228,11 @@ export function readClaudeCliCredentials(options?: {
|
|||||||
allowKeychainPrompt?: boolean;
|
allowKeychainPrompt?: boolean;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
|
execSync?: ExecSyncFn;
|
||||||
}): ClaudeCliCredential | null {
|
}): ClaudeCliCredential | null {
|
||||||
const platform = options?.platform ?? process.platform;
|
const platform = options?.platform ?? process.platform;
|
||||||
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
||||||
if (keychainCreds) {
|
if (keychainCreds) {
|
||||||
log.info("read anthropic credentials from claude cli keychain", {
|
log.info("read anthropic credentials from claude cli keychain", {
|
||||||
type: keychainCreds.type,
|
type: keychainCreds.type,
|
||||||
@@ -263,6 +279,7 @@ export function readClaudeCliCredentialsCached(options?: {
|
|||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
|
execSync?: ExecSyncFn;
|
||||||
}): ClaudeCliCredential | null {
|
}): ClaudeCliCredential | null {
|
||||||
const ttlMs = options?.ttlMs ?? 0;
|
const ttlMs = options?.ttlMs ?? 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -279,6 +296,7 @@ export function readClaudeCliCredentialsCached(options?: {
|
|||||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||||
platform: options?.platform,
|
platform: options?.platform,
|
||||||
homeDir: options?.homeDir,
|
homeDir: options?.homeDir,
|
||||||
|
execSync: options?.execSync,
|
||||||
});
|
});
|
||||||
if (ttlMs > 0) {
|
if (ttlMs > 0) {
|
||||||
claudeCliCache = { value, readAt: now, cacheKey };
|
claudeCliCache = { value, readAt: now, cacheKey };
|
||||||
@@ -286,9 +304,13 @@ export function readClaudeCliCredentialsCached(options?: {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentials): boolean {
|
export function writeClaudeCliKeychainCredentials(
|
||||||
|
newCredentials: OAuthCredentials,
|
||||||
|
options?: { execSync?: ExecSyncFn },
|
||||||
|
): boolean {
|
||||||
|
const execSyncImpl = options?.execSync ?? execSync;
|
||||||
try {
|
try {
|
||||||
const existingResult = execSync(
|
const existingResult = execSyncImpl(
|
||||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
|
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
|
||||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
@@ -308,7 +330,7 @@ export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentia
|
|||||||
|
|
||||||
const newValue = JSON.stringify(existingData);
|
const newValue = JSON.stringify(existingData);
|
||||||
|
|
||||||
execSync(
|
execSyncImpl(
|
||||||
`security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`,
|
`security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`,
|
||||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
@@ -385,9 +407,11 @@ export function writeClaudeCliCredentials(
|
|||||||
|
|
||||||
export function readCodexCliCredentials(options?: {
|
export function readCodexCliCredentials(options?: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
|
execSync?: ExecSyncFn;
|
||||||
}): CodexCliCredential | null {
|
}): CodexCliCredential | null {
|
||||||
const keychain = readCodexKeychainCredentials({
|
const keychain = readCodexKeychainCredentials({
|
||||||
platform: options?.platform,
|
platform: options?.platform,
|
||||||
|
execSync: options?.execSync,
|
||||||
});
|
});
|
||||||
if (keychain) return keychain;
|
if (keychain) return keychain;
|
||||||
|
|
||||||
@@ -425,6 +449,7 @@ export function readCodexCliCredentials(options?: {
|
|||||||
export function readCodexCliCredentialsCached(options?: {
|
export function readCodexCliCredentialsCached(options?: {
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
|
execSync?: ExecSyncFn;
|
||||||
}): CodexCliCredential | null {
|
}): CodexCliCredential | null {
|
||||||
const ttlMs = options?.ttlMs ?? 0;
|
const ttlMs = options?.ttlMs ?? 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -437,7 +462,10 @@ export function readCodexCliCredentialsCached(options?: {
|
|||||||
) {
|
) {
|
||||||
return codexCliCache.value;
|
return codexCliCache.value;
|
||||||
}
|
}
|
||||||
const value = readCodexCliCredentials({ platform: options?.platform });
|
const value = readCodexCliCredentials({
|
||||||
|
platform: options?.platform,
|
||||||
|
execSync: options?.execSync,
|
||||||
|
});
|
||||||
if (ttlMs > 0) {
|
if (ttlMs > 0) {
|
||||||
codexCliCache = { value, readAt: now, cacheKey };
|
codexCliCache = { value, readAt: now, cacheKey };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ describe("memory search config", () => {
|
|||||||
expect(resolved).toBeNull();
|
expect(resolved).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults provider to auto when unspecified", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||||
|
expect(resolved?.provider).toBe("auto");
|
||||||
|
expect(resolved?.fallback).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
it("merges defaults and overrides", () => {
|
it("merges defaults and overrides", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agents: {
|
agents: {
|
||||||
@@ -126,6 +141,7 @@ describe("memory search config", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
|
provider: "openai",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://default.example/v1",
|
baseUrl: "https://default.example/v1",
|
||||||
apiKey: "default-key",
|
apiKey: "default-key",
|
||||||
@@ -166,6 +182,7 @@ describe("memory search config", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
|
provider: "openai",
|
||||||
sources: ["memory", "sessions"],
|
sources: ["memory", "sessions"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -189,6 +206,7 @@ describe("memory search config", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
|
provider: "openai",
|
||||||
sources: ["memory", "sessions"],
|
sources: ["memory", "sessions"],
|
||||||
experimental: { sessionMemory: true },
|
experimental: { sessionMemory: true },
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-24
@@ -3,13 +3,13 @@ import path from "node:path";
|
|||||||
|
|
||||||
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
|
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { clampInt, clampNumber, resolveUserPath } from "../utils.js";
|
||||||
import { resolveAgentConfig } from "./agent-scope.js";
|
import { resolveAgentConfig } from "./agent-scope.js";
|
||||||
|
|
||||||
export type ResolvedMemorySearchConfig = {
|
export type ResolvedMemorySearchConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sources: Array<"memory" | "sessions">;
|
sources: Array<"memory" | "sessions">;
|
||||||
provider: "openai" | "gemini" | "local";
|
provider: "openai" | "local" | "gemini" | "auto";
|
||||||
remote?: {
|
remote?: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
sessionMemory: boolean;
|
sessionMemory: boolean;
|
||||||
};
|
};
|
||||||
fallback: "openai" | "none";
|
fallback: "openai" | "gemini" | "local" | "none";
|
||||||
model: string;
|
model: string;
|
||||||
local: {
|
local: {
|
||||||
modelPath?: string;
|
modelPath?: string;
|
||||||
@@ -110,34 +110,40 @@ function mergeConfig(
|
|||||||
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
||||||
const sessionMemory =
|
const sessionMemory =
|
||||||
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
|
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
|
||||||
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
||||||
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
|
const defaultRemote = defaults?.remote;
|
||||||
const includeRemote = hasRemote || provider === "openai" || provider === "gemini";
|
const overrideRemote = overrides?.remote;
|
||||||
|
const hasRemote = Boolean(defaultRemote || overrideRemote);
|
||||||
|
const includeRemote =
|
||||||
|
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||||
const batch = {
|
const batch = {
|
||||||
enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
|
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||||
wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
|
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||||
concurrency: Math.max(
|
concurrency: Math.max(
|
||||||
1,
|
1,
|
||||||
overrides?.remote?.batch?.concurrency ?? defaults?.remote?.batch?.concurrency ?? 2,
|
overrideRemote?.batch?.concurrency ?? defaultRemote?.batch?.concurrency ?? 2,
|
||||||
),
|
),
|
||||||
pollIntervalMs:
|
pollIntervalMs:
|
||||||
overrides?.remote?.batch?.pollIntervalMs ?? defaults?.remote?.batch?.pollIntervalMs ?? 2000,
|
overrideRemote?.batch?.pollIntervalMs ?? defaultRemote?.batch?.pollIntervalMs ?? 2000,
|
||||||
timeoutMinutes:
|
timeoutMinutes:
|
||||||
overrides?.remote?.batch?.timeoutMinutes ?? defaults?.remote?.batch?.timeoutMinutes ?? 60,
|
overrideRemote?.batch?.timeoutMinutes ?? defaultRemote?.batch?.timeoutMinutes ?? 60,
|
||||||
};
|
};
|
||||||
const remote = includeRemote
|
const remote = includeRemote
|
||||||
? {
|
? {
|
||||||
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
|
baseUrl: overrideRemote?.baseUrl ?? defaultRemote?.baseUrl,
|
||||||
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
|
apiKey: overrideRemote?.apiKey ?? defaultRemote?.apiKey,
|
||||||
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
|
headers: overrideRemote?.headers ?? defaultRemote?.headers,
|
||||||
batch,
|
batch,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
const fallback = overrides?.fallback ?? defaults?.fallback ?? "none";
|
||||||
const model =
|
const modelDefault =
|
||||||
overrides?.model ??
|
provider === "gemini"
|
||||||
defaults?.model ??
|
? DEFAULT_GEMINI_MODEL
|
||||||
(provider === "gemini" ? DEFAULT_GEMINI_MODEL : DEFAULT_OPENAI_MODEL);
|
: provider === "openai"
|
||||||
|
? DEFAULT_OPENAI_MODEL
|
||||||
|
: undefined;
|
||||||
|
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
|
||||||
const local = {
|
const local = {
|
||||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||||
@@ -194,14 +200,14 @@ function mergeConfig(
|
|||||||
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
|
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
|
const overlap = clampNumber(chunking.overlap, 0, Math.max(0, chunking.tokens - 1));
|
||||||
const minScore = Math.max(0, Math.min(1, query.minScore));
|
const minScore = clampNumber(query.minScore, 0, 1);
|
||||||
const vectorWeight = Math.max(0, Math.min(1, hybrid.vectorWeight));
|
const vectorWeight = clampNumber(hybrid.vectorWeight, 0, 1);
|
||||||
const textWeight = Math.max(0, Math.min(1, hybrid.textWeight));
|
const textWeight = clampNumber(hybrid.textWeight, 0, 1);
|
||||||
const sum = vectorWeight + textWeight;
|
const sum = vectorWeight + textWeight;
|
||||||
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
||||||
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
||||||
const candidateMultiplier = Math.max(1, Math.min(20, Math.floor(hybrid.candidateMultiplier)));
|
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20);
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-emb
|
|||||||
describe("image dimension errors", () => {
|
describe("image dimension errors", () => {
|
||||||
it("parses anthropic image dimension errors", () => {
|
it("parses anthropic image dimension errors", () => {
|
||||||
const raw =
|
const raw =
|
||||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
|
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}';
|
||||||
const parsed = parseImageDimensionError(raw);
|
const parsed = parseImageDimensionError(raw);
|
||||||
expect(parsed).not.toBeNull();
|
expect(parsed).not.toBeNull();
|
||||||
expect(parsed?.maxDimensionPx).toBe(2000);
|
expect(parsed?.maxDimensionPx).toBe(2000);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("isCloudCodeAssistFormatError", () => {
|
|||||||
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
isCloudCodeAssistFormatError(
|
isCloudCodeAssistFormatError(
|
||||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
|
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
-1
@@ -89,7 +89,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
-1
@@ -91,7 +91,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
mockPiAi();
|
mockPiAi();
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("detectImageReferences", () => {
|
|||||||
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
|
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
|
||||||
expect(refs[0]?.type).toBe("path");
|
expect(refs[0]?.type).toBe("path");
|
||||||
// Resolved path should expand ~
|
// Resolved path should expand ~
|
||||||
expect(refs[0]?.resolved).not.toContain("~");
|
expect(refs[0]?.resolved?.startsWith("~")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects multiple image references in a prompt", () => {
|
it("detects multiple image references in a prompt", () => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ vi.mock("../config/sessions.js", () => ({
|
|||||||
resolveAgentIdFromSessionKey: () => "main",
|
resolveAgentIdFromSessionKey: () => "main",
|
||||||
resolveStorePath: () => "/tmp/sessions.json",
|
resolveStorePath: () => "/tmp/sessions.json",
|
||||||
resolveMainSessionKey: () => "agent:main:main",
|
resolveMainSessionKey: () => "agent:main:main",
|
||||||
|
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -94,9 +94,12 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain("- Read: Read file contents");
|
expect(prompt).toContain("- Read: Read file contents");
|
||||||
expect(prompt).toContain("- Exec: Run shell commands");
|
expect(prompt).toContain("- Exec: Run shell commands");
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it.",
|
||||||
);
|
);
|
||||||
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes docs guidance when docsPath is provided", () => {
|
it("includes docs guidance when docsPath is provided", () => {
|
||||||
@@ -185,7 +188,7 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
|
|
||||||
expect(prompt).toContain("## Skills");
|
expect(prompt).toContain("## Skills");
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Use `read` to load the SKILL.md at the location listed for that skill.",
|
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ function buildSkillsSection(params: {
|
|||||||
const trimmed = params.skillsPrompt?.trim();
|
const trimmed = params.skillsPrompt?.trim();
|
||||||
if (!trimmed || params.isMinimal) return [];
|
if (!trimmed || params.isMinimal) return [];
|
||||||
return [
|
return [
|
||||||
"## Skills",
|
"## Skills (mandatory)",
|
||||||
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
"Before replying: scan <available_skills> <description> entries.",
|
||||||
|
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it.`,
|
||||||
|
"- If multiple could apply: choose the most specific one, then read/follow it.",
|
||||||
|
"- If none clearly apply: do not read any SKILL.md.",
|
||||||
|
"Constraints: never read more than one skill up front; only read after selecting.",
|
||||||
trimmed,
|
trimmed,
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
@@ -109,11 +113,7 @@ function buildMessagingSection(params: {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDocsSection(params: {
|
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
|
||||||
docsPath?: string;
|
|
||||||
isMinimal: boolean;
|
|
||||||
readToolName: string;
|
|
||||||
}) {
|
|
||||||
const docsPath = params.docsPath?.trim();
|
const docsPath = params.docsPath?.trim();
|
||||||
if (!docsPath || params.isMinimal) return [];
|
if (!docsPath || params.isMinimal) return [];
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
const height = meta?.height;
|
const height = meta?.height;
|
||||||
const overBytes = buf.byteLength > params.maxBytes;
|
const overBytes = buf.byteLength > params.maxBytes;
|
||||||
const hasDimensions = typeof width === "number" && typeof height === "number";
|
const hasDimensions = typeof width === "number" && typeof height === "number";
|
||||||
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
if (
|
||||||
|
hasDimensions &&
|
||||||
|
!overBytes &&
|
||||||
|
width <= params.maxDimensionPx &&
|
||||||
|
height <= params.maxDimensionPx
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
base64: params.base64,
|
base64: params.base64,
|
||||||
mimeType: params.mimeType,
|
mimeType: params.mimeType,
|
||||||
@@ -67,7 +72,10 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
height,
|
height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
|
if (
|
||||||
|
hasDimensions &&
|
||||||
|
(width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)
|
||||||
|
) {
|
||||||
log.warn("Image exceeds limits; resizing", {
|
log.warn("Image exceeds limits; resizing", {
|
||||||
label: params.label,
|
label: params.label,
|
||||||
width,
|
width,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export type NodeListNode = {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
deviceFamily?: string;
|
deviceFamily?: string;
|
||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
@@ -20,6 +23,8 @@ type PendingRequest = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
isRepair?: boolean;
|
isRepair?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -31,6 +36,8 @@ type PairedNode = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
import {
|
||||||
|
getChannelPlugin,
|
||||||
|
normalizeChannelId as normalizeAnyChannelId,
|
||||||
|
} from "../../channels/plugins/index.js";
|
||||||
|
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
|
||||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||||
@@ -21,7 +25,7 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
|||||||
const id = rest.join(":").trim();
|
const id = rest.join(":").trim();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
if (!channelRaw) return null;
|
if (!channelRaw) return null;
|
||||||
const normalizedChannel = normalizeChannelId(channelRaw);
|
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
|
||||||
const channel = normalizedChannel ?? channelRaw.toLowerCase();
|
const channel = normalizedChannel ?? channelRaw.toLowerCase();
|
||||||
const kindTarget = (() => {
|
const kindTarget = (() => {
|
||||||
if (!normalizedChannel) return id;
|
if (!normalizedChannel) return id;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js";
|
import {
|
||||||
|
formatAgentEnvelope,
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "./envelope.js";
|
||||||
|
|
||||||
describe("formatAgentEnvelope", () => {
|
describe("formatAgentEnvelope", () => {
|
||||||
it("includes channel, from, ip, host, and timestamp", () => {
|
it("includes channel, from, ip, host, and timestamp", () => {
|
||||||
@@ -38,6 +42,46 @@ describe("formatAgentEnvelope", () => {
|
|||||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("formats timestamps in local timezone when configured", () => {
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
process.env.TZ = "America/Los_Angeles";
|
||||||
|
|
||||||
|
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (19:04 PST)
|
||||||
|
const body = formatAgentEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
timestamp: ts,
|
||||||
|
envelope: { timezone: "local" },
|
||||||
|
body: "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
|
||||||
|
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats timestamps in user timezone when configured", () => {
|
||||||
|
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (04:04 CET)
|
||||||
|
const body = formatAgentEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
timestamp: ts,
|
||||||
|
envelope: { timezone: "user", userTimezone: "Europe/Vienna" },
|
||||||
|
body: "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toMatch(/\[WebChat 2025-01-02 04:04 [^\]]+\] hello/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits timestamps when configured", () => {
|
||||||
|
const ts = Date.UTC(2025, 0, 2, 3, 4);
|
||||||
|
const body = formatAgentEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
timestamp: ts,
|
||||||
|
envelope: { includeTimestamp: false },
|
||||||
|
body: "hello",
|
||||||
|
});
|
||||||
|
expect(body).toBe("[WebChat] hello");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles missing optional fields", () => {
|
it("handles missing optional fields", () => {
|
||||||
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
|
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
|
||||||
expect(body).toBe("[Telegram] hi");
|
expect(body).toBe("[Telegram] hi");
|
||||||
@@ -77,4 +121,53 @@ describe("formatInboundEnvelope", () => {
|
|||||||
});
|
});
|
||||||
expect(body).toBe("[iMessage +1555] hello");
|
expect(body).toBe("[iMessage +1555] hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes elapsed time when previousTimestamp is provided", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const twoMinutesAgo = now - 2 * 60 * 1000;
|
||||||
|
const body = formatInboundEnvelope({
|
||||||
|
channel: "Telegram",
|
||||||
|
from: "Alice",
|
||||||
|
body: "follow-up message",
|
||||||
|
timestamp: now,
|
||||||
|
previousTimestamp: twoMinutesAgo,
|
||||||
|
chatType: "direct",
|
||||||
|
envelope: { includeTimestamp: false },
|
||||||
|
});
|
||||||
|
expect(body).toContain("Alice +2m");
|
||||||
|
expect(body).toContain("follow-up message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits elapsed time when disabled", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const body = formatInboundEnvelope({
|
||||||
|
channel: "Telegram",
|
||||||
|
from: "Alice",
|
||||||
|
body: "follow-up message",
|
||||||
|
timestamp: now,
|
||||||
|
previousTimestamp: now - 2 * 60 * 1000,
|
||||||
|
chatType: "direct",
|
||||||
|
envelope: { includeElapsed: false, includeTimestamp: false },
|
||||||
|
});
|
||||||
|
expect(body).toBe("[Telegram Alice] follow-up message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves envelope options from config", () => {
|
||||||
|
const options = resolveEnvelopeFormatOptions({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "user",
|
||||||
|
envelopeTimestamp: "off",
|
||||||
|
envelopeElapsed: "off",
|
||||||
|
userTimezone: "Europe/Vienna",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(options).toEqual({
|
||||||
|
timezone: "user",
|
||||||
|
includeTimestamp: false,
|
||||||
|
includeElapsed: false,
|
||||||
|
userTimezone: "Europe/Vienna",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+158
-9
@@ -1,5 +1,7 @@
|
|||||||
|
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||||
import { normalizeChatType } from "../channels/chat-type.js";
|
import { normalizeChatType } from "../channels/chat-type.js";
|
||||||
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
export type AgentEnvelopeParams = {
|
export type AgentEnvelopeParams = {
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -8,31 +10,172 @@ export type AgentEnvelopeParams = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
previousTimestamp?: number | Date;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestamp(ts?: number | Date): string | undefined {
|
export type EnvelopeFormatOptions = {
|
||||||
if (!ts) return undefined;
|
/**
|
||||||
const date = ts instanceof Date ? ts : new Date(ts);
|
* "utc" (default), "local", "user", or an explicit IANA timezone string.
|
||||||
if (Number.isNaN(date.getTime())) return undefined;
|
*/
|
||||||
|
timezone?: string;
|
||||||
|
/**
|
||||||
|
* Include absolute timestamps in the envelope (default: true).
|
||||||
|
*/
|
||||||
|
includeTimestamp?: boolean;
|
||||||
|
/**
|
||||||
|
* Include elapsed time suffix when previousTimestamp is provided (default: true).
|
||||||
|
*/
|
||||||
|
includeElapsed?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional user timezone used when timezone="user".
|
||||||
|
*/
|
||||||
|
userTimezone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedEnvelopeOptions = {
|
||||||
|
timezone: string;
|
||||||
|
includeTimestamp: boolean;
|
||||||
|
includeElapsed: boolean;
|
||||||
|
userTimezone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedEnvelopeTimezone =
|
||||||
|
| { mode: "utc" }
|
||||||
|
| { mode: "local" }
|
||||||
|
| { mode: "iana"; timeZone: string };
|
||||||
|
|
||||||
|
export function resolveEnvelopeFormatOptions(cfg?: ClawdbotConfig): EnvelopeFormatOptions {
|
||||||
|
const defaults = cfg?.agents?.defaults;
|
||||||
|
return {
|
||||||
|
timezone: defaults?.envelopeTimezone,
|
||||||
|
includeTimestamp: defaults?.envelopeTimestamp !== "off",
|
||||||
|
includeElapsed: defaults?.envelopeElapsed !== "off",
|
||||||
|
userTimezone: defaults?.userTimezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEnvelopeOptions {
|
||||||
|
const includeTimestamp = options?.includeTimestamp !== false;
|
||||||
|
const includeElapsed = options?.includeElapsed !== false;
|
||||||
|
return {
|
||||||
|
timezone: options?.timezone?.trim() || "utc",
|
||||||
|
includeTimestamp,
|
||||||
|
includeElapsed,
|
||||||
|
userTimezone: options?.userTimezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExplicitTimezone(value: string): string | undefined {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||||
|
const trimmed = options.timezone?.trim();
|
||||||
|
if (!trimmed) return { mode: "utc" };
|
||||||
|
const lowered = trimmed.toLowerCase();
|
||||||
|
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||||
|
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||||
|
if (lowered === "user") {
|
||||||
|
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
||||||
|
}
|
||||||
|
const explicit = resolveExplicitTimezone(trimmed);
|
||||||
|
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcTimestamp(date: Date): string {
|
||||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||||
|
|
||||||
// Compact ISO-like UTC timestamp with minutes precision.
|
|
||||||
// Example: 2025-01-02T03:04Z
|
|
||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||||
|
const yyyy = pick("year");
|
||||||
|
const mm = pick("month");
|
||||||
|
const dd = pick("day");
|
||||||
|
const hh = pick("hour");
|
||||||
|
const min = pick("minute");
|
||||||
|
const tz = [...parts]
|
||||||
|
.reverse()
|
||||||
|
.find((part) => part.type === "timeZoneName")
|
||||||
|
?.value?.trim();
|
||||||
|
if (!yyyy || !mm || !dd || !hh || !min) return undefined;
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(
|
||||||
|
ts: number | Date | undefined,
|
||||||
|
options?: EnvelopeFormatOptions,
|
||||||
|
): string | undefined {
|
||||||
|
if (!ts) return undefined;
|
||||||
|
const resolved = normalizeEnvelopeOptions(options);
|
||||||
|
if (!resolved.includeTimestamp) return undefined;
|
||||||
|
const date = ts instanceof Date ? ts : new Date(ts);
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
|
const zone = resolveEnvelopeTimezone(resolved);
|
||||||
|
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||||
|
if (zone.mode === "local") return formatZonedTimestamp(date);
|
||||||
|
return formatZonedTimestamp(date, zone.timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
|
||||||
|
const elapsedMs = currentMs - previousMs;
|
||||||
|
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined;
|
||||||
|
|
||||||
|
const seconds = Math.floor(elapsedMs / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||||
const channel = params.channel?.trim() || "Channel";
|
const channel = params.channel?.trim() || "Channel";
|
||||||
const parts: string[] = [channel];
|
const parts: string[] = [channel];
|
||||||
if (params.from?.trim()) parts.push(params.from.trim());
|
const resolved = normalizeEnvelopeOptions(params.envelope);
|
||||||
|
const elapsed =
|
||||||
|
resolved.includeElapsed && params.timestamp && params.previousTimestamp
|
||||||
|
? formatElapsedTime(
|
||||||
|
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
|
||||||
|
params.previousTimestamp instanceof Date
|
||||||
|
? params.previousTimestamp.getTime()
|
||||||
|
: params.previousTimestamp,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (params.from?.trim()) {
|
||||||
|
const from = params.from.trim();
|
||||||
|
parts.push(elapsed ? `${from} +${elapsed}` : from);
|
||||||
|
} else if (elapsed) {
|
||||||
|
parts.push(`+${elapsed}`);
|
||||||
|
}
|
||||||
if (params.host?.trim()) parts.push(params.host.trim());
|
if (params.host?.trim()) parts.push(params.host.trim());
|
||||||
if (params.ip?.trim()) parts.push(params.ip.trim());
|
if (params.ip?.trim()) parts.push(params.ip.trim());
|
||||||
const ts = formatTimestamp(params.timestamp);
|
const ts = formatTimestamp(params.timestamp, resolved);
|
||||||
if (ts) parts.push(ts);
|
if (ts) parts.push(ts);
|
||||||
const header = `[${parts.join(" ")}]`;
|
const header = `[${parts.join(" ")}]`;
|
||||||
return `${header} ${params.body}`;
|
return `${header} ${params.body}`;
|
||||||
@@ -46,6 +189,8 @@ export function formatInboundEnvelope(params: {
|
|||||||
chatType?: string;
|
chatType?: string;
|
||||||
senderLabel?: string;
|
senderLabel?: string;
|
||||||
sender?: SenderLabelParams;
|
sender?: SenderLabelParams;
|
||||||
|
previousTimestamp?: number | Date;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
}): string {
|
}): string {
|
||||||
const chatType = normalizeChatType(params.chatType);
|
const chatType = normalizeChatType(params.chatType);
|
||||||
const isDirect = !chatType || chatType === "direct";
|
const isDirect = !chatType || chatType === "direct";
|
||||||
@@ -55,6 +200,8 @@ export function formatInboundEnvelope(params: {
|
|||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
from: params.from,
|
from: params.from,
|
||||||
timestamp: params.timestamp,
|
timestamp: params.timestamp,
|
||||||
|
previousTimestamp: params.previousTimestamp,
|
||||||
|
envelope: params.envelope,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -85,11 +232,13 @@ export function formatThreadStarterEnvelope(params: {
|
|||||||
author?: string;
|
author?: string;
|
||||||
timestamp?: number | Date;
|
timestamp?: number | Date;
|
||||||
body: string;
|
body: string;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
}): string {
|
}): string {
|
||||||
return formatAgentEnvelope({
|
return formatAgentEnvelope({
|
||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
from: params.author,
|
from: params.author,
|
||||||
timestamp: params.timestamp,
|
timestamp: params.timestamp,
|
||||||
|
envelope: params.envelope,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
import {
|
||||||
|
emitAgentEvent,
|
||||||
|
registerAgentRunContext,
|
||||||
|
} from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
isMarkdownCapableMessageChannel,
|
isMarkdownCapableMessageChannel,
|
||||||
@@ -29,11 +32,20 @@ import type { TemplateContext } from "../templating.js";
|
|||||||
import type { VerboseLevel } from "../thinking.js";
|
import type { VerboseLevel } from "../thinking.js";
|
||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
import {
|
||||||
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
buildThreadingToolContext,
|
||||||
|
resolveEnforceFinalTag,
|
||||||
|
} from "./agent-runner-utils.js";
|
||||||
|
import {
|
||||||
|
createBlockReplyPayloadKey,
|
||||||
|
type BlockReplyPipeline,
|
||||||
|
} from "./block-reply-pipeline.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import { parseReplyDirectives } from "./reply-directives.js";
|
import { parseReplyDirectives } from "./reply-directives.js";
|
||||||
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
import {
|
||||||
|
applyReplyTagsToPayload,
|
||||||
|
isRenderablePayload,
|
||||||
|
} from "./reply-payloads.js";
|
||||||
import type { TypingSignaler } from "./typing-mode.js";
|
import type { TypingSignaler } from "./typing-mode.js";
|
||||||
|
|
||||||
export type AgentRunLoopResult =
|
export type AgentRunLoopResult =
|
||||||
@@ -96,9 +108,12 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const allowPartialStream = !(
|
const allowPartialStream = !(
|
||||||
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
|
params.followupRun.run.reasoningLevel === "stream" &&
|
||||||
|
params.opts?.onReasoningStream
|
||||||
);
|
);
|
||||||
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
const normalizeStreamingText = (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
): { text?: string; skip: boolean } => {
|
||||||
if (!allowPartialStream) return { skip: true };
|
if (!allowPartialStream) return { skip: true };
|
||||||
let text = payload.text;
|
let text = payload.text;
|
||||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||||
@@ -122,7 +137,9 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
if (!sanitized.trim()) return { skip: true };
|
if (!sanitized.trim()) return { skip: true };
|
||||||
return { text: sanitized, skip: false };
|
return { text: sanitized, skip: false };
|
||||||
};
|
};
|
||||||
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
|
const handlePartialForTyping = async (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
const { text, skip } = normalizeStreamingText(payload);
|
const { text, skip } = normalizeStreamingText(payload);
|
||||||
if (skip || !text) return undefined;
|
if (skip || !text) return undefined;
|
||||||
await params.typingSignals.signalTextDelta(text);
|
await params.typingSignals.signalTextDelta(text);
|
||||||
@@ -157,7 +174,10 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
startedAt,
|
startedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
|
const cliSessionId = getCliSessionId(
|
||||||
|
params.getActiveSessionEntry(),
|
||||||
|
provider,
|
||||||
|
);
|
||||||
return runCliAgent({
|
return runCliAgent({
|
||||||
sessionId: params.followupRun.run.sessionId,
|
sessionId: params.followupRun.run.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
@@ -207,7 +227,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
return runEmbeddedPiAgent({
|
return runEmbeddedPiAgent({
|
||||||
sessionId: params.followupRun.run.sessionId,
|
sessionId: params.followupRun.run.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
messageProvider:
|
||||||
|
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||||
agentAccountId: params.sessionCtx.AccountId,
|
agentAccountId: params.sessionCtx.AccountId,
|
||||||
// Provider threading context for tool auto-injection
|
// Provider threading context for tool auto-injection
|
||||||
...buildThreadingToolContext({
|
...buildThreadingToolContext({
|
||||||
@@ -223,7 +244,10 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
prompt: params.commandBody,
|
prompt: params.commandBody,
|
||||||
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
|
||||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
ownerNumbers: params.followupRun.run.ownerNumbers,
|
||||||
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
|
enforceFinalTag: resolveEnforceFinalTag(
|
||||||
|
params.followupRun.run,
|
||||||
|
provider,
|
||||||
|
),
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
authProfileId,
|
authProfileId,
|
||||||
@@ -240,7 +264,9 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
params.sessionCtx.Provider,
|
params.sessionCtx.Provider,
|
||||||
);
|
);
|
||||||
if (!channel) return "markdown";
|
if (!channel) return "markdown";
|
||||||
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
return isMarkdownCapableMessageChannel(channel)
|
||||||
|
? "markdown"
|
||||||
|
: "plain";
|
||||||
})(),
|
})(),
|
||||||
bashElevated: params.followupRun.run.bashElevated,
|
bashElevated: params.followupRun.run.bashElevated,
|
||||||
timeoutMs: params.followupRun.run.timeoutMs,
|
timeoutMs: params.followupRun.run.timeoutMs,
|
||||||
@@ -250,7 +276,11 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
onPartialReply: allowPartialStream
|
onPartialReply: allowPartialStream
|
||||||
? async (payload) => {
|
? async (payload) => {
|
||||||
const textForTyping = await handlePartialForTyping(payload);
|
const textForTyping = await handlePartialForTyping(payload);
|
||||||
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
|
if (
|
||||||
|
!params.opts?.onPartialReply ||
|
||||||
|
textForTyping === undefined
|
||||||
|
)
|
||||||
|
return;
|
||||||
await params.opts.onPartialReply({
|
await params.opts.onPartialReply({
|
||||||
text: textForTyping,
|
text: textForTyping,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
@@ -261,7 +291,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
await params.typingSignals.signalMessageStart();
|
await params.typingSignals.signalMessageStart();
|
||||||
},
|
},
|
||||||
onReasoningStream:
|
onReasoningStream:
|
||||||
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
|
params.typingSignals.shouldStartOnReasoning ||
|
||||||
|
params.opts?.onReasoningStream
|
||||||
? async (payload) => {
|
? async (payload) => {
|
||||||
await params.typingSignals.signalReasoningDelta();
|
await params.typingSignals.signalReasoningDelta();
|
||||||
await params.opts?.onReasoningStream?.({
|
await params.opts?.onReasoningStream?.({
|
||||||
@@ -274,14 +305,16 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
// Trigger typing when tools start executing.
|
// Trigger typing when tools start executing.
|
||||||
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
||||||
if (evt.stream === "tool") {
|
if (evt.stream === "tool") {
|
||||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
const phase =
|
||||||
|
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||||
if (phase === "start" || phase === "update") {
|
if (phase === "start" || phase === "update") {
|
||||||
await params.typingSignals.signalToolStart();
|
await params.typingSignals.signalToolStart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Track auto-compaction completion
|
// Track auto-compaction completion
|
||||||
if (evt.stream === "compaction") {
|
if (evt.stream === "compaction") {
|
||||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
const phase =
|
||||||
|
typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||||
const willRetry = Boolean(evt.data.willRetry);
|
const willRetry = Boolean(evt.data.willRetry);
|
||||||
if (phase === "end" && !willRetry) {
|
if (phase === "end" && !willRetry) {
|
||||||
autoCompactionCompleted = true;
|
autoCompactionCompleted = true;
|
||||||
@@ -305,14 +338,22 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
params.sessionCtx.MessageSid,
|
params.sessionCtx.MessageSid,
|
||||||
);
|
);
|
||||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
if (
|
||||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
!isRenderablePayload(taggedPayload) &&
|
||||||
currentMessageId: params.sessionCtx.MessageSid,
|
!payload.audioAsVoice
|
||||||
silentToken: SILENT_REPLY_TOKEN,
|
)
|
||||||
});
|
return;
|
||||||
|
const parsed = parseReplyDirectives(
|
||||||
|
taggedPayload.text ?? "",
|
||||||
|
{
|
||||||
|
currentMessageId: params.sessionCtx.MessageSid,
|
||||||
|
silentToken: SILENT_REPLY_TOKEN,
|
||||||
|
},
|
||||||
|
);
|
||||||
const cleaned = parsed.text || undefined;
|
const cleaned = parsed.text || undefined;
|
||||||
const hasRenderableMedia =
|
const hasRenderableMedia =
|
||||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
Boolean(taggedPayload.mediaUrl) ||
|
||||||
|
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||||
if (
|
if (
|
||||||
!cleaned &&
|
!cleaned &&
|
||||||
@@ -326,25 +367,35 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||||
...taggedPayload,
|
...taggedPayload,
|
||||||
text: cleaned,
|
text: cleaned,
|
||||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
audioAsVoice: Boolean(
|
||||||
|
parsed.audioAsVoice || payload.audioAsVoice,
|
||||||
|
),
|
||||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
replyToCurrent:
|
||||||
|
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||||
});
|
});
|
||||||
|
|
||||||
void params.typingSignals
|
void params.typingSignals
|
||||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
logVerbose(
|
||||||
|
`block reply typing signal failed: ${String(err)}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use pipeline if available (block streaming enabled), otherwise send directly
|
// Use pipeline if available (block streaming enabled), otherwise send directly
|
||||||
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
|
if (
|
||||||
|
params.blockStreamingEnabled &&
|
||||||
|
params.blockReplyPipeline
|
||||||
|
) {
|
||||||
params.blockReplyPipeline.enqueue(blockPayload);
|
params.blockReplyPipeline.enqueue(blockPayload);
|
||||||
} else {
|
} else {
|
||||||
// Send directly when flushing before tool execution (no streaming).
|
// Send directly when flushing before tool execution (no streaming).
|
||||||
// Track sent key to avoid duplicate in final payloads.
|
// Track sent key to avoid duplicate in final payloads.
|
||||||
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
|
directlySentBlockKeys.add(
|
||||||
|
createBlockReplyPayloadKey(blockPayload),
|
||||||
|
);
|
||||||
await params.opts?.onBlockReply?.(blockPayload);
|
await params.opts?.onBlockReply?.(blockPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,17 +441,27 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
// Some embedded runs surface context overflow as an error payload instead of throwing.
|
// Some embedded runs surface context overflow as an error payload instead of throwing.
|
||||||
// Treat those as a session-level failure and auto-recover by starting a fresh session.
|
// Treat those as a session-level failure and auto-recover by starting a fresh session.
|
||||||
const embeddedError = runResult.meta?.error;
|
const embeddedError = runResult.meta?.error;
|
||||||
if (
|
if (embeddedError && isContextOverflowError(embeddedError.message)) {
|
||||||
embeddedError &&
|
if (
|
||||||
isContextOverflowError(embeddedError.message) &&
|
!didResetAfterCompactionFailure &&
|
||||||
!didResetAfterCompactionFailure &&
|
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
|
||||||
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
|
) {
|
||||||
) {
|
didResetAfterCompactionFailure = true;
|
||||||
didResetAfterCompactionFailure = true;
|
continue;
|
||||||
continue;
|
}
|
||||||
|
if (didResetAfterCompactionFailure) {
|
||||||
|
return {
|
||||||
|
kind: "final",
|
||||||
|
payload: {
|
||||||
|
text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (embeddedError?.kind === "role_ordering") {
|
if (embeddedError?.kind === "role_ordering") {
|
||||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
|
const didReset = await params.resetSessionAfterRoleOrderingConflict(
|
||||||
|
embeddedError.message,
|
||||||
|
);
|
||||||
if (didReset) {
|
if (didReset) {
|
||||||
return {
|
return {
|
||||||
kind: "final",
|
kind: "final",
|
||||||
@@ -418,8 +479,10 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
isContextOverflowError(message) ||
|
isContextOverflowError(message) ||
|
||||||
/context.*overflow|too large|context window/i.test(message);
|
/context.*overflow|too large|context window/i.test(message);
|
||||||
const isCompactionFailure = isCompactionFailureError(message);
|
const isCompactionFailure = isCompactionFailureError(message);
|
||||||
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
|
const isSessionCorruption =
|
||||||
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
|
/function call turn comes immediately after/i.test(message);
|
||||||
|
const isRoleOrderingError =
|
||||||
|
/incorrect role information|roles must alternate/i.test(message);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isCompactionFailure &&
|
isCompactionFailure &&
|
||||||
@@ -429,8 +492,17 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
didResetAfterCompactionFailure = true;
|
didResetAfterCompactionFailure = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isCompactionFailure && didResetAfterCompactionFailure) {
|
||||||
|
return {
|
||||||
|
kind: "final",
|
||||||
|
payload: {
|
||||||
|
text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (isRoleOrderingError) {
|
if (isRoleOrderingError) {
|
||||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
|
const didReset =
|
||||||
|
await params.resetSessionAfterRoleOrderingConflict(message);
|
||||||
if (didReset) {
|
if (didReset) {
|
||||||
return {
|
return {
|
||||||
kind: "final",
|
kind: "final",
|
||||||
@@ -457,7 +529,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
try {
|
try {
|
||||||
// Delete transcript file if it exists
|
// Delete transcript file if it exists
|
||||||
if (corruptedSessionId) {
|
if (corruptedSessionId) {
|
||||||
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
|
const transcriptPath =
|
||||||
|
resolveSessionTranscriptPath(corruptedSessionId);
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(transcriptPath);
|
fs.unlinkSync(transcriptPath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -507,6 +580,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
fallbackModel,
|
fallbackModel,
|
||||||
didLogHeartbeatStrip,
|
didLogHeartbeatStrip,
|
||||||
autoCompactionCompleted,
|
autoCompactionCompleted,
|
||||||
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
directlySentBlockKeys:
|
||||||
|
directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+47
@@ -172,6 +172,53 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
it("returns a user-facing error after repeated compaction failure", async () => {
|
||||||
|
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-repeat-"));
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
|
try {
|
||||||
|
const sessionId = "session";
|
||||||
|
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||||
|
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||||
|
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||||
|
const sessionStore = { main: sessionEntry };
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
|
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||||
|
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||||
|
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockImplementation(async () => {
|
||||||
|
throw new Error(
|
||||||
|
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
|
||||||
|
const { run } = createMinimalRun({
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "main",
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
const res = await run();
|
||||||
|
|
||||||
|
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
|
||||||
|
const payload = Array.isArray(res) ? res[0] : res;
|
||||||
|
expect(payload.text).toContain("Context limit exceeded");
|
||||||
|
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||||
|
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
|
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||||
|
} finally {
|
||||||
|
if (prevStateDir) {
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||||
|
} else {
|
||||||
|
delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
it("retries after context overflow payload by resetting the session", async () => {
|
it("retries after context overflow payload by resetting the session", async () => {
|
||||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));
|
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
|||||||
import { killProcessTree } from "../../agents/shell-utils.js";
|
import { killProcessTree } from "../../agents/shell-utils.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { clampInt } from "../../utils.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||||
@@ -31,14 +32,10 @@ type ActiveBashJob =
|
|||||||
|
|
||||||
let activeJob: ActiveBashJob | null = null;
|
let activeJob: ActiveBashJob | null = null;
|
||||||
|
|
||||||
function clampNumber(value: number, min: number, max: number) {
|
|
||||||
return Math.min(Math.max(value, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveForegroundMs(cfg: ClawdbotConfig): number {
|
function resolveForegroundMs(cfg: ClawdbotConfig): number {
|
||||||
const raw = cfg.commands?.bashForegroundMs;
|
const raw = cfg.commands?.bashForegroundMs;
|
||||||
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
|
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
|
||||||
return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
|
return clampInt(raw, 0, MAX_FOREGROUND_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionSnippet(sessionId: string) {
|
function formatSessionSnippet(sessionId: string) {
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const callGatewayFromCli = vi.fn(
|
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
|
||||||
async (method: string, _opts: unknown, params?: unknown) => {
|
if (method.endsWith(".get")) {
|
||||||
if (method.endsWith(".get")) {
|
return {
|
||||||
return {
|
path: "/tmp/exec-approvals.json",
|
||||||
path: "/tmp/exec-approvals.json",
|
exists: true,
|
||||||
exists: true,
|
hash: "hash-1",
|
||||||
hash: "hash-1",
|
file: { version: 1, agents: {} },
|
||||||
file: { version: 1, agents: {} },
|
};
|
||||||
};
|
}
|
||||||
}
|
return { method, params };
|
||||||
return { method, params };
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const runtimeLogs: string[] = [];
|
const runtimeLogs: string[] = [];
|
||||||
const runtimeErrors: string[] = [];
|
const runtimeErrors: string[] = [];
|
||||||
@@ -31,9 +29,7 @@ vi.mock("./gateway-rpc.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./nodes-cli/rpc.js", async () => {
|
vi.mock("./nodes-cli/rpc.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
|
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>("./nodes-cli/rpc.js");
|
||||||
"./nodes-cli/rpc.js",
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
resolveNodeId: vi.fn(async () => "node-1"),
|
resolveNodeId: vi.fn(async () => "node-1"),
|
||||||
@@ -57,11 +53,7 @@ describe("exec approvals CLI", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["approvals", "get"], { from: "user" });
|
await program.parseAsync(["approvals", "get"], { from: "user" });
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
|
||||||
"exec.approvals.get",
|
|
||||||
expect.anything(),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,11 +69,9 @@ describe("exec approvals CLI", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
|
||||||
"exec.approvals.node.get",
|
nodeId: "node-1",
|
||||||
expect.anything(),
|
});
|
||||||
{ nodeId: "node-1" },
|
|
||||||
);
|
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -151,15 +151,13 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
});
|
});
|
||||||
nodesCallOpts(setCmd);
|
nodesCallOpts(setCmd);
|
||||||
|
|
||||||
const allowlist = approvals
|
const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
|
||||||
.command("allowlist")
|
|
||||||
.description("Edit the per-agent allowlist");
|
|
||||||
|
|
||||||
const allowlistAdd = allowlist
|
const allowlistAdd = allowlist
|
||||||
.command("add <pattern>")
|
.command("add <pattern>")
|
||||||
.description("Add a glob pattern to an allowlist")
|
.description("Add a glob pattern to an allowlist")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
.option("--agent <id>", 'Agent id (defaults to "default")')
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||||
const trimmed = pattern.trim();
|
const trimmed = pattern.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -196,7 +194,7 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
.command("remove <pattern>")
|
.command("remove <pattern>")
|
||||||
.description("Remove a glob pattern from an allowlist")
|
.description("Remove a glob pattern from an allowlist")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
.option("--agent <id>", 'Agent id (defaults to "default")')
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||||
const trimmed = pattern.trim();
|
const trimmed = pattern.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@@ -87,12 +87,14 @@ describe("gateway SIGTERM", () => {
|
|||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const err: string[] = [];
|
const err: string[] = [];
|
||||||
|
|
||||||
|
const bunBin = process.env.BUN_INSTALL
|
||||||
|
? path.join(process.env.BUN_INSTALL, "bin", "bun")
|
||||||
|
: "bun";
|
||||||
|
|
||||||
child = spawn(
|
child = spawn(
|
||||||
process.execPath,
|
bunBin,
|
||||||
[
|
[
|
||||||
"--import",
|
"src/entry.ts",
|
||||||
"tsx",
|
|
||||||
"src/index.ts",
|
|
||||||
"gateway",
|
"gateway",
|
||||||
"--port",
|
"--port",
|
||||||
String(port),
|
String(port),
|
||||||
|
|||||||
@@ -248,69 +248,6 @@ describe("memory cli", () => {
|
|||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies index mode overrides", async () => {
|
|
||||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
|
||||||
const close = vi.fn(async () => {});
|
|
||||||
const sync = vi.fn(async () => {});
|
|
||||||
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
|
||||||
loadConfig.mockReturnValueOnce({
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
memorySearch: {
|
|
||||||
remote: {
|
|
||||||
batch: { enabled: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
getMemorySearchManager.mockResolvedValueOnce({
|
|
||||||
manager: {
|
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
|
||||||
probeEmbeddingAvailability,
|
|
||||||
sync,
|
|
||||||
status: () => ({
|
|
||||||
files: 1,
|
|
||||||
chunks: 1,
|
|
||||||
dirty: false,
|
|
||||||
workspaceDir: "/tmp/clawd",
|
|
||||||
dbPath: "/tmp/memory.sqlite",
|
|
||||||
provider: "openai",
|
|
||||||
model: "text-embedding-3-small",
|
|
||||||
requestedProvider: "openai",
|
|
||||||
vector: { enabled: true, available: true },
|
|
||||||
}),
|
|
||||||
close,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.name("test");
|
|
||||||
registerMemoryCli(program);
|
|
||||||
await program.parseAsync(["memory", "status", "--index", "--index-mode", "direct"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getMemorySearchManager).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
cfg: expect.objectContaining({
|
|
||||||
agents: expect.objectContaining({
|
|
||||||
defaults: expect.objectContaining({
|
|
||||||
memorySearch: expect.objectContaining({
|
|
||||||
remote: expect.objectContaining({
|
|
||||||
batch: expect.objectContaining({ enabled: false }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(sync).toHaveBeenCalled();
|
|
||||||
expect(probeEmbeddingAvailability).toHaveBeenCalled();
|
|
||||||
expect(close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes manager after index", async () => {
|
it("closes manager after index", async () => {
|
||||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
@@ -329,31 +266,9 @@ describe("memory cli", () => {
|
|||||||
registerMemoryCli(program);
|
registerMemoryCli(program);
|
||||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
|
||||||
);
|
|
||||||
expect(close).toHaveBeenCalled();
|
|
||||||
expect(log).toHaveBeenCalledWith("Memory index updated.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips progress when --progress none", async () => {
|
|
||||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
|
||||||
const close = vi.fn(async () => {});
|
|
||||||
const sync = vi.fn(async () => {});
|
|
||||||
getMemorySearchManager.mockResolvedValueOnce({
|
|
||||||
manager: {
|
|
||||||
sync,
|
|
||||||
close,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.name("test");
|
|
||||||
registerMemoryCli(program);
|
|
||||||
await program.parseAsync(["memory", "index", "--progress", "none"], { from: "user" });
|
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
|
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs close failures without failing the command", async () => {
|
it("logs close failures without failing the command", async () => {
|
||||||
@@ -376,9 +291,7 @@ describe("memory cli", () => {
|
|||||||
registerMemoryCli(program);
|
registerMemoryCli(program);
|
||||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith(
|
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
||||||
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
|
||||||
);
|
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Memory manager close failed: close boom"),
|
expect.stringContaining("Memory manager close failed: close boom"),
|
||||||
|
|||||||
+237
-277
@@ -1,36 +1,39 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import type { MemorySearchConfig } from "../config/types.tools.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { setVerbose } from "../globals.js";
|
import { setVerbose } from "../globals.js";
|
||||||
import { withProgress, withProgressTotals } from "./progress.js";
|
import { withProgress, withProgressTotals } from "./progress.js";
|
||||||
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
||||||
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
||||||
import {
|
|
||||||
resolveMemoryCacheState,
|
|
||||||
resolveMemoryFtsState,
|
|
||||||
resolveMemoryVectorState,
|
|
||||||
type Tone,
|
|
||||||
} from "../memory/status-format.js";
|
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
|
||||||
type MemoryCommandOptions = {
|
type MemoryCommandOptions = {
|
||||||
agent?: string;
|
agent?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
deep?: boolean;
|
deep?: boolean;
|
||||||
index?: boolean;
|
index?: boolean;
|
||||||
indexMode?: IndexMode;
|
|
||||||
progress?: ProgressMode;
|
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
|
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
|
||||||
type IndexMode = "auto" | "batch" | "direct";
|
|
||||||
type ProgressMode = "auto" | "line" | "log" | "none";
|
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
|
||||||
|
if (source === "memory") {
|
||||||
|
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
|
||||||
|
}
|
||||||
|
if (source === "sessions") {
|
||||||
|
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||||
|
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
||||||
const trimmed = agent?.trim();
|
const trimmed = agent?.trim();
|
||||||
@@ -38,66 +41,14 @@ function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
|||||||
return resolveDefaultAgentId(cfg);
|
return resolveDefaultAgentId(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveIndexMode(raw?: string): IndexMode {
|
function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, agent?: string): string[] {
|
||||||
if (!raw) return "auto";
|
const trimmed = agent?.trim();
|
||||||
const trimmed = raw.trim().toLowerCase();
|
if (trimmed) return [trimmed];
|
||||||
if (trimmed === "batch") return "batch";
|
const list = cfg.agents?.list ?? [];
|
||||||
if (trimmed === "direct") return "direct";
|
if (list.length > 0) {
|
||||||
return "auto";
|
return list.map((entry) => entry.id).filter(Boolean);
|
||||||
}
|
|
||||||
|
|
||||||
function resolveProgressMode(raw?: string): ProgressMode {
|
|
||||||
if (!raw) return "auto";
|
|
||||||
const trimmed = raw.trim().toLowerCase();
|
|
||||||
if (trimmed === "line") return "line";
|
|
||||||
if (trimmed === "log") return "log";
|
|
||||||
if (trimmed === "none") return "none";
|
|
||||||
return "auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyIndexMode(cfg: ClawdbotConfig, agentId: string, mode: IndexMode): ClawdbotConfig {
|
|
||||||
if (mode === "auto") return cfg;
|
|
||||||
const enabled = mode === "batch";
|
|
||||||
const patchMemorySearch = (memorySearch?: MemorySearchConfig) => {
|
|
||||||
const remote = memorySearch?.remote;
|
|
||||||
const batch = remote?.batch;
|
|
||||||
return {
|
|
||||||
...memorySearch,
|
|
||||||
remote: {
|
|
||||||
...remote,
|
|
||||||
batch: {
|
|
||||||
...batch,
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const nextAgents = { ...cfg.agents };
|
|
||||||
nextAgents.defaults = {
|
|
||||||
...cfg.agents?.defaults,
|
|
||||||
memorySearch: patchMemorySearch(cfg.agents?.defaults?.memorySearch),
|
|
||||||
};
|
|
||||||
if (cfg.agents?.list?.length) {
|
|
||||||
nextAgents.list = cfg.agents.list.map((agent) =>
|
|
||||||
agent.id === agentId
|
|
||||||
? {
|
|
||||||
...agent,
|
|
||||||
memorySearch: patchMemorySearch(agent.memorySearch),
|
|
||||||
}
|
|
||||||
: agent,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return { ...cfg, agents: nextAgents };
|
return [resolveDefaultAgentId(cfg)];
|
||||||
}
|
|
||||||
|
|
||||||
function resolveProgressOptions(
|
|
||||||
mode: ProgressMode,
|
|
||||||
verbose: boolean,
|
|
||||||
): { enabled?: boolean; fallback?: "spinner" | "line" | "log" | "none" } {
|
|
||||||
if (mode === "none") return { enabled: false, fallback: "none" };
|
|
||||||
if (mode === "line") return { fallback: "line" };
|
|
||||||
if (mode === "log") return { fallback: "log" };
|
|
||||||
return { fallback: verbose ? "line" : undefined };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMemoryCli(program: Command) {
|
export function registerMemoryCli(program: Command) {
|
||||||
@@ -117,182 +68,190 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.option("--json", "Print JSON")
|
.option("--json", "Print JSON")
|
||||||
.option("--deep", "Probe embedding provider availability")
|
.option("--deep", "Probe embedding provider availability")
|
||||||
.option("--index", "Reindex if dirty (implies --deep)")
|
.option("--index", "Reindex if dirty (implies --deep)")
|
||||||
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
|
|
||||||
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
|
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.action(async (opts: MemoryCommandOptions) => {
|
.action(async (opts: MemoryCommandOptions) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const rawCfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const agentId = resolveAgent(rawCfg, opts.agent);
|
const agentIds = resolveAgentIds(cfg, opts.agent);
|
||||||
const indexMode = resolveIndexMode(opts.indexMode);
|
const allResults: Array<{
|
||||||
const progressMode = resolveProgressMode(opts.progress);
|
agentId: string;
|
||||||
const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
|
status: ReturnType<MemoryManager["status"]>;
|
||||||
const cfg = applyIndexMode(rawCfg, agentId, indexMode);
|
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
|
||||||
await withManager<MemoryManager>({
|
indexError?: string;
|
||||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
}> = [];
|
||||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
|
||||||
onCloseError: (err) =>
|
for (const agentId of agentIds) {
|
||||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
await withManager<MemoryManager>({
|
||||||
close: (manager) => manager.close(),
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
run: async (manager) => {
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||||
const deep = Boolean(opts.deep || opts.index);
|
onCloseError: (err) =>
|
||||||
let embeddingProbe:
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||||
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
|
close: (manager) => manager.close(),
|
||||||
| undefined;
|
run: async (manager) => {
|
||||||
let indexError: string | undefined;
|
const deep = Boolean(opts.deep || opts.index);
|
||||||
if (deep) {
|
let embeddingProbe:
|
||||||
await withProgress(
|
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
|
||||||
{ label: "Checking memory…", total: 2, ...progressOptions },
|
| undefined;
|
||||||
async (progress) => {
|
let indexError: string | undefined;
|
||||||
|
if (deep) {
|
||||||
|
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
|
||||||
progress.setLabel("Probing vector…");
|
progress.setLabel("Probing vector…");
|
||||||
await manager.probeVectorAvailability();
|
await manager.probeVectorAvailability();
|
||||||
progress.tick();
|
progress.tick();
|
||||||
progress.setLabel("Probing embeddings…");
|
progress.setLabel("Probing embeddings…");
|
||||||
embeddingProbe = await manager.probeEmbeddingAvailability();
|
embeddingProbe = await manager.probeEmbeddingAvailability();
|
||||||
progress.tick();
|
progress.tick();
|
||||||
},
|
});
|
||||||
);
|
if (opts.index) {
|
||||||
if (opts.index) {
|
await withProgressTotals(
|
||||||
await withProgressTotals(
|
{
|
||||||
{
|
label: "Indexing memory…",
|
||||||
label: "Indexing memory…",
|
total: 0,
|
||||||
total: 0,
|
fallback: opts.verbose ? "line" : undefined,
|
||||||
...progressOptions,
|
},
|
||||||
},
|
async (update, progress) => {
|
||||||
async (update, progress) => {
|
try {
|
||||||
try {
|
await manager.sync({
|
||||||
await manager.sync({
|
reason: "cli",
|
||||||
reason: "cli",
|
progress: (syncUpdate) => {
|
||||||
progress: (syncUpdate) => {
|
update({
|
||||||
update({
|
completed: syncUpdate.completed,
|
||||||
completed: syncUpdate.completed,
|
total: syncUpdate.total,
|
||||||
total: syncUpdate.total,
|
label: syncUpdate.label,
|
||||||
label: syncUpdate.label,
|
});
|
||||||
});
|
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
||||||
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
},
|
||||||
},
|
});
|
||||||
});
|
} catch (err) {
|
||||||
} catch (err) {
|
indexError = formatErrorMessage(err);
|
||||||
indexError = formatErrorMessage(err);
|
defaultRuntime.error(`Memory index failed: ${indexError}`);
|
||||||
defaultRuntime.error(`Memory index failed: ${indexError}`);
|
process.exitCode = 1;
|
||||||
process.exitCode = 1;
|
}
|
||||||
}
|
},
|
||||||
},
|
);
|
||||||
);
|
}
|
||||||
|
} else {
|
||||||
|
await manager.probeVectorAvailability();
|
||||||
}
|
}
|
||||||
} else {
|
const status = manager.status();
|
||||||
await manager.probeVectorAvailability();
|
allResults.push({ agentId, status, embeddingProbe, indexError });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(allResults, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rich = isRich();
|
||||||
|
const heading = (text: string) => colorize(rich, theme.heading, text);
|
||||||
|
const muted = (text: string) => colorize(rich, theme.muted, text);
|
||||||
|
const info = (text: string) => colorize(rich, theme.info, text);
|
||||||
|
const success = (text: string) => colorize(rich, theme.success, text);
|
||||||
|
const warn = (text: string) => colorize(rich, theme.warn, text);
|
||||||
|
const accent = (text: string) => colorize(rich, theme.accent, text);
|
||||||
|
const label = (text: string) => muted(`${text}:`);
|
||||||
|
|
||||||
|
for (const result of allResults) {
|
||||||
|
const { agentId, status, embeddingProbe, indexError } = result;
|
||||||
|
if (opts.index) {
|
||||||
|
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
|
||||||
|
defaultRuntime.log(line);
|
||||||
|
}
|
||||||
|
const lines = [
|
||||||
|
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
||||||
|
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||||
|
`(requested: ${status.requestedProvider})`,
|
||||||
|
)}`,
|
||||||
|
`${label("Model")} ${info(status.model)}`,
|
||||||
|
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
||||||
|
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
||||||
|
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||||
|
`${label("Store")} ${info(status.dbPath)}`,
|
||||||
|
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
if (embeddingProbe) {
|
||||||
|
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
||||||
|
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
|
||||||
|
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
|
||||||
|
if (embeddingProbe.error) {
|
||||||
|
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
||||||
}
|
}
|
||||||
const status = manager.status();
|
}
|
||||||
if (opts.json) {
|
if (status.sourceCounts?.length) {
|
||||||
defaultRuntime.log(
|
lines.push(label("By source"));
|
||||||
JSON.stringify(
|
for (const entry of status.sourceCounts) {
|
||||||
{
|
const counts = `${entry.files} files · ${entry.chunks} chunks`;
|
||||||
...status,
|
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
|
||||||
embeddings: embeddingProbe
|
|
||||||
? { ok: embeddingProbe.ok, error: embeddingProbe.error }
|
|
||||||
: undefined,
|
|
||||||
indexError,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (opts.index) {
|
}
|
||||||
const line = indexError
|
if (status.fallback) {
|
||||||
? `Memory index failed: ${indexError}`
|
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
||||||
: "Memory index complete.";
|
}
|
||||||
defaultRuntime.log(line);
|
if (status.vector) {
|
||||||
|
const vectorState = status.vector.enabled
|
||||||
|
? status.vector.available
|
||||||
|
? "ready"
|
||||||
|
: "unavailable"
|
||||||
|
: "disabled";
|
||||||
|
const vectorColor =
|
||||||
|
vectorState === "ready"
|
||||||
|
? theme.success
|
||||||
|
: vectorState === "unavailable"
|
||||||
|
? theme.warn
|
||||||
|
: theme.muted;
|
||||||
|
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
|
||||||
|
if (status.vector.dims) {
|
||||||
|
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
||||||
}
|
}
|
||||||
const rich = isRich();
|
if (status.vector.extensionPath) {
|
||||||
const heading = (text: string) => colorize(rich, theme.heading, text);
|
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
||||||
const muted = (text: string) => colorize(rich, theme.muted, text);
|
|
||||||
const info = (text: string) => colorize(rich, theme.info, text);
|
|
||||||
const success = (text: string) => colorize(rich, theme.success, text);
|
|
||||||
const warn = (text: string) => colorize(rich, theme.warn, text);
|
|
||||||
const accent = (text: string) => colorize(rich, theme.accent, text);
|
|
||||||
const label = (text: string) => muted(`${text}:`);
|
|
||||||
const colorForTone = (tone: Tone) =>
|
|
||||||
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
|
|
||||||
const lines = [
|
|
||||||
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
|
||||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
|
||||||
`(requested: ${status.requestedProvider})`,
|
|
||||||
)}`,
|
|
||||||
`${label("Model")} ${info(status.model)}`,
|
|
||||||
status.sources?.length
|
|
||||||
? `${label("Sources")} ${info(status.sources.join(", "))}`
|
|
||||||
: null,
|
|
||||||
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
|
||||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
|
||||||
`${label("Store")} ${info(status.dbPath)}`,
|
|
||||||
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
|
||||||
].filter(Boolean) as string[];
|
|
||||||
if (embeddingProbe) {
|
|
||||||
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
|
||||||
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
|
|
||||||
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
|
|
||||||
if (embeddingProbe.error) {
|
|
||||||
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (status.sourceCounts?.length) {
|
if (status.vector.loadError) {
|
||||||
lines.push(label("By source"));
|
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
||||||
for (const entry of status.sourceCounts) {
|
|
||||||
const counts = `${entry.files} files · ${entry.chunks} chunks`;
|
|
||||||
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (status.fallback) {
|
}
|
||||||
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
if (status.fts) {
|
||||||
|
const ftsState = status.fts.enabled
|
||||||
|
? status.fts.available
|
||||||
|
? "ready"
|
||||||
|
: "unavailable"
|
||||||
|
: "disabled";
|
||||||
|
const ftsColor =
|
||||||
|
ftsState === "ready"
|
||||||
|
? theme.success
|
||||||
|
: ftsState === "unavailable"
|
||||||
|
? theme.warn
|
||||||
|
: theme.muted;
|
||||||
|
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
|
||||||
|
if (status.fts.error) {
|
||||||
|
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
|
||||||
}
|
}
|
||||||
if (status.vector) {
|
}
|
||||||
const vectorState = resolveMemoryVectorState(status.vector);
|
if (status.cache) {
|
||||||
const vectorColor = colorForTone(vectorState.tone);
|
const cacheState = status.cache.enabled ? "enabled" : "disabled";
|
||||||
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`);
|
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
|
||||||
if (status.vector.dims) {
|
const suffix =
|
||||||
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
status.cache.enabled && typeof status.cache.entries === "number"
|
||||||
}
|
? ` (${status.cache.entries} entries)`
|
||||||
if (status.vector.extensionPath) {
|
: "";
|
||||||
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
lines.push(
|
||||||
}
|
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
|
||||||
if (status.vector.loadError) {
|
);
|
||||||
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
|
||||||
}
|
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
||||||
}
|
}
|
||||||
if (status.fts) {
|
}
|
||||||
const ftsState = resolveMemoryFtsState(status.fts);
|
if (status.fallback?.reason) {
|
||||||
const ftsColor = colorForTone(ftsState.tone);
|
lines.push(muted(status.fallback.reason));
|
||||||
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`);
|
}
|
||||||
if (status.fts.error) {
|
if (indexError) {
|
||||||
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
|
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
||||||
}
|
}
|
||||||
}
|
defaultRuntime.log(lines.join("\n"));
|
||||||
if (status.cache) {
|
if (agentIds.length > 1) defaultRuntime.log("");
|
||||||
const cacheState = resolveMemoryCacheState(status.cache);
|
}
|
||||||
const cacheColor = colorForTone(cacheState.tone);
|
|
||||||
const suffix =
|
|
||||||
status.cache.enabled && typeof status.cache.entries === "number"
|
|
||||||
? ` (${status.cache.entries} entries)`
|
|
||||||
: "";
|
|
||||||
lines.push(
|
|
||||||
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`,
|
|
||||||
);
|
|
||||||
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
|
|
||||||
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (status.fallback?.reason) {
|
|
||||||
lines.push(muted(status.fallback.reason));
|
|
||||||
}
|
|
||||||
if (indexError) {
|
|
||||||
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
|
||||||
}
|
|
||||||
defaultRuntime.log(lines.join("\n"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
memory
|
memory
|
||||||
@@ -300,56 +259,57 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.description("Reindex memory files")
|
.description("Reindex memory files")
|
||||||
.option("--agent <id>", "Agent id (default: default agent)")
|
.option("--agent <id>", "Agent id (default: default agent)")
|
||||||
.option("--force", "Force full reindex", false)
|
.option("--force", "Force full reindex", false)
|
||||||
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
|
.option("--verbose", "Verbose logging", false)
|
||||||
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
|
|
||||||
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
||||||
const rawCfg = loadConfig();
|
setVerbose(Boolean(opts.verbose));
|
||||||
const agentId = resolveAgent(rawCfg, opts.agent);
|
const cfg = loadConfig();
|
||||||
const indexMode = resolveIndexMode(opts.indexMode);
|
const agentIds = resolveAgentIds(cfg, opts.agent);
|
||||||
const progressMode = resolveProgressMode(opts.progress);
|
for (const agentId of agentIds) {
|
||||||
const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
|
await withManager<MemoryManager>({
|
||||||
const cfg = applyIndexMode(rawCfg, agentId, indexMode);
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
await withManager<MemoryManager>({
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
onCloseError: (err) =>
|
||||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||||
onCloseError: (err) =>
|
close: (manager) => manager.close(),
|
||||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
run: async (manager) => {
|
||||||
close: (manager) => manager.close(),
|
try {
|
||||||
run: async (manager) => {
|
if (opts.verbose) {
|
||||||
try {
|
const status = manager.status();
|
||||||
if (progressMode === "none") {
|
const rich = isRich();
|
||||||
|
const heading = (text: string) => colorize(rich, theme.heading, text);
|
||||||
|
const muted = (text: string) => colorize(rich, theme.muted, text);
|
||||||
|
const info = (text: string) => colorize(rich, theme.info, text);
|
||||||
|
const warn = (text: string) => colorize(rich, theme.warn, text);
|
||||||
|
const label = (text: string) => muted(`${text}:`);
|
||||||
|
const sourceLabels = status.sources.map((source) =>
|
||||||
|
formatSourceLabel(source, status.workspaceDir, agentId),
|
||||||
|
);
|
||||||
|
const lines = [
|
||||||
|
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
|
||||||
|
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||||
|
`(requested: ${status.requestedProvider})`,
|
||||||
|
)}`,
|
||||||
|
`${label("Model")} ${info(status.model)}`,
|
||||||
|
sourceLabels.length
|
||||||
|
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
if (status.fallback) {
|
||||||
|
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
||||||
|
}
|
||||||
|
defaultRuntime.log(lines.join("\n"));
|
||||||
|
defaultRuntime.log("");
|
||||||
|
}
|
||||||
await manager.sync({ reason: "cli", force: opts.force });
|
await manager.sync({ reason: "cli", force: opts.force });
|
||||||
} else {
|
defaultRuntime.log(`Memory index updated (${agentId}).`);
|
||||||
await withProgressTotals(
|
} catch (err) {
|
||||||
{
|
const message = formatErrorMessage(err);
|
||||||
label: "Indexing memory…",
|
defaultRuntime.error(`Memory index failed (${agentId}): ${message}`);
|
||||||
total: 0,
|
process.exitCode = 1;
|
||||||
...progressOptions,
|
|
||||||
},
|
|
||||||
async (update, progress) => {
|
|
||||||
await manager.sync({
|
|
||||||
reason: "cli",
|
|
||||||
force: opts.force,
|
|
||||||
progress: (syncUpdate) => {
|
|
||||||
update({
|
|
||||||
completed: syncUpdate.completed,
|
|
||||||
total: syncUpdate.total,
|
|
||||||
label: syncUpdate.label,
|
|
||||||
});
|
|
||||||
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
defaultRuntime.log("Memory index updated.");
|
},
|
||||||
} catch (err) {
|
});
|
||||||
const message = formatErrorMessage(err);
|
}
|
||||||
defaultRuntime.error(`Memory index failed: ${message}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
memory
|
memory
|
||||||
|
|||||||
@@ -4,6 +4,43 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
|
|||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
|
|
||||||
|
function formatVersionLabel(raw: string) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return raw;
|
||||||
|
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
|
||||||
|
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodeVersions(node: {
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
|
}) {
|
||||||
|
const core = node.coreVersion?.trim() || undefined;
|
||||||
|
const ui = node.uiVersion?.trim() || undefined;
|
||||||
|
if (core || ui) return { core, ui };
|
||||||
|
const legacy = node.version?.trim();
|
||||||
|
if (!legacy) return { core: undefined, ui: undefined };
|
||||||
|
const platform = node.platform?.trim().toLowerCase() ?? "";
|
||||||
|
const headless =
|
||||||
|
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
|
||||||
|
return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNodeVersions(node: {
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
|
}) {
|
||||||
|
const { core, ui } = resolveNodeVersions(node);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (core) parts.push(`core ${formatVersionLabel(core)}`);
|
||||||
|
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerNodesStatusCommands(nodes: Command) {
|
export function registerNodesStatusCommands(nodes: Command) {
|
||||||
nodesCallOpts(
|
nodesCallOpts(
|
||||||
nodes
|
nodes
|
||||||
@@ -29,6 +66,8 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
|
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
|
||||||
const perms = formatPermissions(n.permissions);
|
const perms = formatPermissions(n.permissions);
|
||||||
const permsText = perms ? ` · perms: ${perms}` : "";
|
const permsText = perms ? ` · perms: ${perms}` : "";
|
||||||
|
const versions = formatNodeVersions(n);
|
||||||
|
const versionText = versions ? ` · ${versions}` : "";
|
||||||
const caps =
|
const caps =
|
||||||
Array.isArray(n.caps) && n.caps.length > 0
|
Array.isArray(n.caps) && n.caps.length > 0
|
||||||
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
|
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
|
||||||
@@ -37,7 +76,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
: "?";
|
: "?";
|
||||||
const pairing = n.paired ? "paired" : "unpaired";
|
const pairing = n.paired ? "paired" : "unpaired";
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
|
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -77,12 +116,21 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
||||||
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
||||||
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
||||||
|
const versions = formatNodeVersions(
|
||||||
|
obj as {
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const parts: string[] = ["Node:", displayName, nodeId];
|
const parts: string[] = ["Node:", displayName, nodeId];
|
||||||
if (ip) parts.push(ip);
|
if (ip) parts.push(ip);
|
||||||
if (family) parts.push(`device: ${family}`);
|
if (family) parts.push(`device: ${family}`);
|
||||||
if (model) parts.push(`hw: ${model}`);
|
if (model) parts.push(`hw: ${model}`);
|
||||||
if (perms) parts.push(`perms: ${perms}`);
|
if (perms) parts.push(`perms: ${perms}`);
|
||||||
|
if (versions) parts.push(versions);
|
||||||
parts.push(connected ? "connected" : "disconnected");
|
parts.push(connected ? "connected" : "disconnected");
|
||||||
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
|
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
|
||||||
defaultRuntime.log(parts.join(" · "));
|
defaultRuntime.log(parts.join(" · "));
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export type NodeListNode = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
deviceFamily?: string;
|
deviceFamily?: string;
|
||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
@@ -62,6 +64,8 @@ export type PendingRequest = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
isRepair?: boolean;
|
isRepair?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -73,6 +77,8 @@ export type PairedNode = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
coreVersion?: string;
|
||||||
|
uiVersion?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ vi.mock("../config/config.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("pairing cli", () => {
|
describe("pairing cli", () => {
|
||||||
|
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
|
||||||
|
listPairingChannels.mockClear();
|
||||||
|
|
||||||
|
const { registerPairingCli } = await import("./pairing-cli.js");
|
||||||
|
expect(listPairingChannels).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.name("test");
|
||||||
|
registerPairingCli(program);
|
||||||
|
|
||||||
|
expect(listPairingChannels).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("labels Telegram ids as telegramUserId", async () => {
|
it("labels Telegram ids as telegramUserId", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
const { registerPairingCli } = await import("./pairing-cli.js");
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([
|
listChannelPairingRequests.mockResolvedValueOnce([
|
||||||
|
|||||||
+9
-10
@@ -11,10 +11,8 @@ import {
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
const CHANNELS: PairingChannel[] = listPairingChannels();
|
|
||||||
|
|
||||||
/** Parse channel, allowing extension channels not in core registry. */
|
/** Parse channel, allowing extension channels not in core registry. */
|
||||||
function parseChannel(raw: unknown): PairingChannel {
|
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
|
||||||
const value = (
|
const value = (
|
||||||
typeof raw === "string"
|
typeof raw === "string"
|
||||||
? raw
|
? raw
|
||||||
@@ -28,7 +26,7 @@ function parseChannel(raw: unknown): PairingChannel {
|
|||||||
|
|
||||||
const normalized = normalizeChannelId(value);
|
const normalized = normalizeChannelId(value);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
if (!CHANNELS.includes(normalized as PairingChannel)) {
|
if (!channels.includes(normalized as PairingChannel)) {
|
||||||
throw new Error(`Channel ${normalized} does not support pairing`);
|
throw new Error(`Channel ${normalized} does not support pairing`);
|
||||||
}
|
}
|
||||||
return normalized as PairingChannel;
|
return normalized as PairingChannel;
|
||||||
@@ -45,6 +43,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerPairingCli(program: Command) {
|
export function registerPairingCli(program: Command) {
|
||||||
|
const channels = listPairingChannels();
|
||||||
const pairing = program
|
const pairing = program
|
||||||
.command("pairing")
|
.command("pairing")
|
||||||
.description("Secure DM pairing (approve inbound requests)")
|
.description("Secure DM pairing (approve inbound requests)")
|
||||||
@@ -57,17 +56,17 @@ export function registerPairingCli(program: Command) {
|
|||||||
pairing
|
pairing
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List pending pairing requests")
|
.description("List pending pairing requests")
|
||||||
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
|
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
||||||
.argument("[channel]", `Channel (${CHANNELS.join(", ")})`)
|
.argument("[channel]", `Channel (${channels.join(", ")})`)
|
||||||
.option("--json", "Print JSON", false)
|
.option("--json", "Print JSON", false)
|
||||||
.action(async (channelArg, opts) => {
|
.action(async (channelArg, opts) => {
|
||||||
const channelRaw = opts.channel ?? channelArg;
|
const channelRaw = opts.channel ?? channelArg;
|
||||||
if (!channelRaw) {
|
if (!channelRaw) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${CHANNELS.join(", ")})`,
|
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${channels.join(", ")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const channel = parseChannel(channelRaw);
|
const channel = parseChannel(channelRaw, channels);
|
||||||
const requests = await listChannelPairingRequests(channel);
|
const requests = await listChannelPairingRequests(channel);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(JSON.stringify({ channel, requests }, null, 2));
|
console.log(JSON.stringify({ channel, requests }, null, 2));
|
||||||
@@ -87,7 +86,7 @@ export function registerPairingCli(program: Command) {
|
|||||||
pairing
|
pairing
|
||||||
.command("approve")
|
.command("approve")
|
||||||
.description("Approve a pairing code and allow that sender")
|
.description("Approve a pairing code and allow that sender")
|
||||||
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
|
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
||||||
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
||||||
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
||||||
.option("--notify", "Notify the requester on the same channel", false)
|
.option("--notify", "Notify the requester on the same channel", false)
|
||||||
@@ -104,7 +103,7 @@ export function registerPairingCli(program: Command) {
|
|||||||
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
|
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const channel = parseChannel(channelRaw);
|
const channel = parseChannel(channelRaw, channels);
|
||||||
const approved = await approveChannelPairingCode({
|
const approved = await approveChannelPairingCode({
|
||||||
channel,
|
channel,
|
||||||
code: String(resolvedCode),
|
code: String(resolvedCode),
|
||||||
|
|||||||
+39
-1
@@ -13,6 +13,7 @@ import {
|
|||||||
resolvePluginInstallDir,
|
resolvePluginInstallDir,
|
||||||
} from "../plugins/install.js";
|
} from "../plugins/install.js";
|
||||||
import { recordPluginInstall } from "../plugins/installs.js";
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
|
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||||
import type { PluginRecord } from "../plugins/registry.js";
|
import type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -79,6 +80,31 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySlotSelectionForPlugin(
|
||||||
|
config: ClawdbotConfig,
|
||||||
|
pluginId: string,
|
||||||
|
): { config: ClawdbotConfig; warnings: string[] } {
|
||||||
|
const report = buildPluginStatusReport({ config });
|
||||||
|
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
||||||
|
if (!plugin) {
|
||||||
|
return { config, warnings: [] };
|
||||||
|
}
|
||||||
|
const result = applyExclusiveSlotSelection({
|
||||||
|
config,
|
||||||
|
selectedId: plugin.id,
|
||||||
|
selectedKind: plugin.kind,
|
||||||
|
registry: report,
|
||||||
|
});
|
||||||
|
return { config: result.config, warnings: result.warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSlotWarnings(warnings: string[]) {
|
||||||
|
if (warnings.length === 0) return;
|
||||||
|
for (const warning of warnings) {
|
||||||
|
defaultRuntime.log(chalk.yellow(warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerPluginsCli(program: Command) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.argument("<id>", "Plugin id")
|
.argument("<id>", "Plugin id")
|
||||||
.action(async (id: string) => {
|
.action(async (id: string) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...cfg.plugins,
|
...cfg.plugins,
|
||||||
@@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, id);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: resolved,
|
installPath: resolved,
|
||||||
version: probe.version,
|
version: probe.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type AuthChoiceGroupId =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
|
| "copilot"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "ai-gateway"
|
| "ai-gateway"
|
||||||
| "moonshot"
|
| "moonshot"
|
||||||
@@ -68,8 +69,14 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||||||
{
|
{
|
||||||
value: "google",
|
value: "google",
|
||||||
label: "Google",
|
label: "Google",
|
||||||
hint: "Gemini API key",
|
hint: "Gemini API key + OAuth",
|
||||||
choices: ["gemini-api-key"],
|
choices: ["gemini-api-key", "google-antigravity", "google-gemini-cli"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "copilot",
|
||||||
|
label: "Copilot",
|
||||||
|
hint: "GitHub + local proxy",
|
||||||
|
choices: ["github-copilot", "copilot-proxy"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "openrouter",
|
value: "openrouter",
|
||||||
@@ -195,8 +202,23 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
hint: "Uses GitHub device flow",
|
hint: "Uses GitHub device flow",
|
||||||
});
|
});
|
||||||
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
||||||
|
options.push({
|
||||||
|
value: "google-antigravity",
|
||||||
|
label: "Google Antigravity OAuth",
|
||||||
|
hint: "Uses the bundled Antigravity auth plugin",
|
||||||
|
});
|
||||||
|
options.push({
|
||||||
|
value: "google-gemini-cli",
|
||||||
|
label: "Google Gemini CLI OAuth",
|
||||||
|
hint: "Uses the bundled Gemini CLI auth plugin",
|
||||||
|
});
|
||||||
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
|
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
|
||||||
options.push({ value: "qwen-portal", label: "Qwen OAuth" });
|
options.push({ value: "qwen-portal", label: "Qwen OAuth" });
|
||||||
|
options.push({
|
||||||
|
value: "copilot-proxy",
|
||||||
|
label: "Copilot Proxy (local)",
|
||||||
|
hint: "Local proxy for VS Code Copilot models",
|
||||||
|
});
|
||||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||||
// Token flow is currently Anthropic-only; use CLI for advanced providers.
|
// Token flow is currently Anthropic-only; use CLI for advanced providers.
|
||||||
options.push({
|
options.push({
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||||
|
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
||||||
|
|
||||||
|
export async function applyAuthChoiceCopilotProxy(
|
||||||
|
params: ApplyAuthChoiceParams,
|
||||||
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
|
return await applyAuthChoicePluginProvider(params, {
|
||||||
|
authChoice: "copilot-proxy",
|
||||||
|
pluginId: "copilot-proxy",
|
||||||
|
providerId: "copilot-proxy",
|
||||||
|
methodId: "local",
|
||||||
|
label: "Copilot Proxy",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||||
|
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
||||||
|
|
||||||
|
export async function applyAuthChoiceGoogleAntigravity(
|
||||||
|
params: ApplyAuthChoiceParams,
|
||||||
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
|
return await applyAuthChoicePluginProvider(params, {
|
||||||
|
authChoice: "google-antigravity",
|
||||||
|
pluginId: "google-antigravity-auth",
|
||||||
|
providerId: "google-antigravity",
|
||||||
|
methodId: "oauth",
|
||||||
|
label: "Google Antigravity",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||||
|
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
||||||
|
|
||||||
|
export async function applyAuthChoiceGoogleGeminiCli(
|
||||||
|
params: ApplyAuthChoiceParams,
|
||||||
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
|
return await applyAuthChoicePluginProvider(params, {
|
||||||
|
authChoice: "google-gemini-cli",
|
||||||
|
pluginId: "google-gemini-cli-auth",
|
||||||
|
providerId: "google-gemini-cli",
|
||||||
|
methodId: "oauth",
|
||||||
|
label: "Google Gemini CLI",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
||||||
|
import {
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
resolveAgentDir,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
|
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||||
|
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||||
|
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||||
|
import { resolvePluginProviders } from "../plugins/providers.js";
|
||||||
|
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
||||||
|
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||||
|
import { applyAuthProfileConfig } from "./onboard-auth.js";
|
||||||
|
import { openUrl } from "./onboard-helpers.js";
|
||||||
|
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
||||||
|
import { isRemoteEnvironment } from "./oauth-env.js";
|
||||||
|
|
||||||
|
export type PluginProviderAuthChoiceOptions = {
|
||||||
|
authChoice: string;
|
||||||
|
pluginId: string;
|
||||||
|
providerId: string;
|
||||||
|
methodId?: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveProviderMatch(
|
||||||
|
providers: ProviderPlugin[],
|
||||||
|
rawProvider: string,
|
||||||
|
): ProviderPlugin | null {
|
||||||
|
const normalized = normalizeProviderId(rawProvider);
|
||||||
|
return (
|
||||||
|
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
|
||||||
|
providers.find(
|
||||||
|
(provider) =>
|
||||||
|
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
|
||||||
|
) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
|
||||||
|
const raw = rawMethod?.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const normalized = raw.toLowerCase();
|
||||||
|
return (
|
||||||
|
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
|
||||||
|
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
||||||
|
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
||||||
|
return patch as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Record<string, unknown> = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
const existing = next[key];
|
||||||
|
if (isPlainRecord(existing) && isPlainRecord(value)) {
|
||||||
|
next[key] = mergeConfigPatch(existing, value);
|
||||||
|
} else {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
|
||||||
|
const models = { ...cfg.agents?.defaults?.models };
|
||||||
|
models[model] = models[model] ?? {};
|
||||||
|
|
||||||
|
const existingModel = cfg.agents?.defaults?.model;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
agents: {
|
||||||
|
...cfg.agents,
|
||||||
|
defaults: {
|
||||||
|
...cfg.agents?.defaults,
|
||||||
|
models,
|
||||||
|
model: {
|
||||||
|
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
|
||||||
|
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
|
||||||
|
: undefined),
|
||||||
|
primary: model,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAuthChoicePluginProvider(
|
||||||
|
params: ApplyAuthChoiceParams,
|
||||||
|
options: PluginProviderAuthChoiceOptions,
|
||||||
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
|
if (params.authChoice !== options.authChoice) return null;
|
||||||
|
|
||||||
|
const enableResult = enablePluginInConfig(params.config, options.pluginId);
|
||||||
|
let nextConfig = enableResult.config;
|
||||||
|
if (!enableResult.enabled) {
|
||||||
|
await params.prompter.note(
|
||||||
|
`${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`,
|
||||||
|
options.label,
|
||||||
|
);
|
||||||
|
return { config: nextConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
|
||||||
|
const defaultAgentId = resolveDefaultAgentId(nextConfig);
|
||||||
|
const agentDir =
|
||||||
|
params.agentDir ??
|
||||||
|
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
|
||||||
|
const workspaceDir =
|
||||||
|
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
|
||||||
|
|
||||||
|
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
|
||||||
|
const provider = resolveProviderMatch(providers, options.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
await params.prompter.note(
|
||||||
|
`${options.label} auth plugin is not available. Enable it and re-run the wizard.`,
|
||||||
|
options.label,
|
||||||
|
);
|
||||||
|
return { config: nextConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0];
|
||||||
|
if (!method) {
|
||||||
|
await params.prompter.note(`${options.label} auth method missing.`, options.label);
|
||||||
|
return { config: nextConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemote = isRemoteEnvironment();
|
||||||
|
const result = await method.run({
|
||||||
|
config: nextConfig,
|
||||||
|
agentDir,
|
||||||
|
workspaceDir,
|
||||||
|
prompter: params.prompter,
|
||||||
|
runtime: params.runtime,
|
||||||
|
isRemote,
|
||||||
|
openUrl: async (url) => {
|
||||||
|
await openUrl(url);
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.configPatch) {
|
||||||
|
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of result.profiles) {
|
||||||
|
upsertAuthProfile({
|
||||||
|
profileId: profile.profileId,
|
||||||
|
credential: profile.credential,
|
||||||
|
agentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
|
profileId: profile.profileId,
|
||||||
|
provider: profile.credential.provider,
|
||||||
|
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
||||||
|
...("email" in profile.credential && profile.credential.email
|
||||||
|
? { email: profile.credential.email }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let agentModelOverride: string | undefined;
|
||||||
|
if (result.defaultModel) {
|
||||||
|
if (params.setDefaultModel) {
|
||||||
|
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
|
||||||
|
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
|
||||||
|
} else if (params.agentId) {
|
||||||
|
agentModelOverride = result.defaultModel;
|
||||||
|
await params.prompter.note(
|
||||||
|
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
|
||||||
|
"Model configured",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.notes && result.notes.length > 0) {
|
||||||
|
await params.prompter.note(result.notes.join("\n"), "Provider notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: nextConfig, agentModelOverride };
|
||||||
|
}
|
||||||
@@ -1,195 +1,14 @@
|
|||||||
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
|
||||||
import {
|
|
||||||
resolveDefaultAgentId,
|
|
||||||
resolveAgentDir,
|
|
||||||
resolveAgentWorkspaceDir,
|
|
||||||
} from "../agents/agent-scope.js";
|
|
||||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
|
||||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import { resolvePluginProviders } from "../plugins/providers.js";
|
|
||||||
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
|
||||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||||
import { applyAuthProfileConfig } from "./onboard-auth.js";
|
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
||||||
import { openUrl } from "./onboard-helpers.js";
|
|
||||||
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
|
||||||
import { isRemoteEnvironment } from "./oauth-env.js";
|
|
||||||
|
|
||||||
const PLUGIN_ID = "qwen-portal-auth";
|
|
||||||
const PROVIDER_ID = "qwen-portal";
|
|
||||||
|
|
||||||
function enableBundledPlugin(cfg: ClawdbotConfig): ClawdbotConfig {
|
|
||||||
const existingEntry = cfg.plugins?.entries?.[PLUGIN_ID];
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
plugins: {
|
|
||||||
...cfg.plugins,
|
|
||||||
entries: {
|
|
||||||
...cfg.plugins?.entries,
|
|
||||||
[PLUGIN_ID]: {
|
|
||||||
...existingEntry,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveProviderMatch(
|
|
||||||
providers: ProviderPlugin[],
|
|
||||||
rawProvider: string,
|
|
||||||
): ProviderPlugin | null {
|
|
||||||
const normalized = normalizeProviderId(rawProvider);
|
|
||||||
return (
|
|
||||||
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
|
|
||||||
providers.find(
|
|
||||||
(provider) =>
|
|
||||||
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
|
|
||||||
) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
|
|
||||||
const raw = rawMethod?.trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
const normalized = raw.toLowerCase();
|
|
||||||
return (
|
|
||||||
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
|
|
||||||
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
|
||||||
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
|
||||||
return patch as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: Record<string, unknown> = { ...base };
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
const existing = next[key];
|
|
||||||
if (isPlainRecord(existing) && isPlainRecord(value)) {
|
|
||||||
next[key] = mergeConfigPatch(existing, value);
|
|
||||||
} else {
|
|
||||||
next[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
|
|
||||||
const models = { ...cfg.agents?.defaults?.models };
|
|
||||||
models[model] = models[model] ?? {};
|
|
||||||
|
|
||||||
const existingModel = cfg.agents?.defaults?.model;
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
agents: {
|
|
||||||
...cfg.agents,
|
|
||||||
defaults: {
|
|
||||||
...cfg.agents?.defaults,
|
|
||||||
models,
|
|
||||||
model: {
|
|
||||||
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
|
|
||||||
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
|
|
||||||
: undefined),
|
|
||||||
primary: model,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyAuthChoiceQwenPortal(
|
export async function applyAuthChoiceQwenPortal(
|
||||||
params: ApplyAuthChoiceParams,
|
params: ApplyAuthChoiceParams,
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
if (params.authChoice !== "qwen-portal") return null;
|
return await applyAuthChoicePluginProvider(params, {
|
||||||
|
authChoice: "qwen-portal",
|
||||||
let nextConfig = enableBundledPlugin(params.config);
|
pluginId: "qwen-portal-auth",
|
||||||
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
|
providerId: "qwen-portal",
|
||||||
const defaultAgentId = resolveDefaultAgentId(nextConfig);
|
methodId: "device",
|
||||||
const agentDir =
|
label: "Qwen",
|
||||||
params.agentDir ??
|
|
||||||
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
|
|
||||||
const workspaceDir =
|
|
||||||
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
|
|
||||||
|
|
||||||
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
|
|
||||||
const provider = resolveProviderMatch(providers, PROVIDER_ID);
|
|
||||||
if (!provider) {
|
|
||||||
await params.prompter.note(
|
|
||||||
"Qwen auth plugin is not available. Run `clawdbot plugins enable qwen-portal-auth` and re-run the wizard.",
|
|
||||||
"Qwen",
|
|
||||||
);
|
|
||||||
return { config: nextConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = pickAuthMethod(provider, "device") ?? provider.auth[0];
|
|
||||||
if (!method) {
|
|
||||||
await params.prompter.note("Qwen auth method missing.", "Qwen");
|
|
||||||
return { config: nextConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRemote = isRemoteEnvironment();
|
|
||||||
const result = await method.run({
|
|
||||||
config: nextConfig,
|
|
||||||
agentDir,
|
|
||||||
workspaceDir,
|
|
||||||
prompter: params.prompter,
|
|
||||||
runtime: params.runtime,
|
|
||||||
isRemote,
|
|
||||||
openUrl: async (url) => {
|
|
||||||
await openUrl(url);
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.configPatch) {
|
|
||||||
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const profile of result.profiles) {
|
|
||||||
upsertAuthProfile({
|
|
||||||
profileId: profile.profileId,
|
|
||||||
credential: profile.credential,
|
|
||||||
agentDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
|
||||||
profileId: profile.profileId,
|
|
||||||
provider: profile.credential.provider,
|
|
||||||
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
|
||||||
...("email" in profile.credential && profile.credential.email
|
|
||||||
? { email: profile.credential.email }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let agentModelOverride: string | undefined;
|
|
||||||
if (result.defaultModel) {
|
|
||||||
if (params.setDefaultModel) {
|
|
||||||
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
|
|
||||||
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
|
|
||||||
} else if (params.agentId) {
|
|
||||||
agentModelOverride = result.defaultModel;
|
|
||||||
await params.prompter.note(
|
|
||||||
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
|
|
||||||
"Model configured",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.notes && result.notes.length > 0) {
|
|
||||||
await params.prompter.note(result.notes.join("\n"), "Provider notes");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { config: nextConfig, agentModelOverride };
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,10 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
|
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
|
||||||
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
|
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
|
||||||
|
import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js";
|
||||||
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
|
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
|
||||||
|
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
|
||||||
|
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
|
||||||
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
|
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
|
||||||
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
|
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
|
||||||
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
|
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
|
||||||
@@ -35,6 +38,9 @@ export async function applyAuthChoice(
|
|||||||
applyAuthChoiceApiProviders,
|
applyAuthChoiceApiProviders,
|
||||||
applyAuthChoiceMiniMax,
|
applyAuthChoiceMiniMax,
|
||||||
applyAuthChoiceGitHubCopilot,
|
applyAuthChoiceGitHubCopilot,
|
||||||
|
applyAuthChoiceGoogleAntigravity,
|
||||||
|
applyAuthChoiceGoogleGeminiCli,
|
||||||
|
applyAuthChoiceCopilotProxy,
|
||||||
applyAuthChoiceQwenPortal,
|
applyAuthChoiceQwenPortal,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
|
|||||||
"moonshot-api-key": "moonshot",
|
"moonshot-api-key": "moonshot",
|
||||||
"kimi-code-api-key": "kimi-code",
|
"kimi-code-api-key": "kimi-code",
|
||||||
"gemini-api-key": "google",
|
"gemini-api-key": "google",
|
||||||
|
"google-antigravity": "google-antigravity",
|
||||||
|
"google-gemini-cli": "google-gemini-cli",
|
||||||
"zai-api-key": "zai",
|
"zai-api-key": "zai",
|
||||||
"synthetic-api-key": "synthetic",
|
"synthetic-api-key": "synthetic",
|
||||||
"github-copilot": "github-copilot",
|
"github-copilot": "github-copilot",
|
||||||
|
"copilot-proxy": "copilot-proxy",
|
||||||
"minimax-cloud": "minimax",
|
"minimax-cloud": "minimax",
|
||||||
"minimax-api": "minimax",
|
"minimax-api": "minimax",
|
||||||
"minimax-api-lightning": "minimax",
|
"minimax-api-lightning": "minimax",
|
||||||
|
|||||||
@@ -417,6 +417,22 @@ describe("channels command", () => {
|
|||||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes Telegram bot username from probe data", () => {
|
||||||
|
const lines = formatGatewayChannelsStatusLines({
|
||||||
|
channelAccounts: {
|
||||||
|
telegram: [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
enabled: true,
|
||||||
|
configured: true,
|
||||||
|
probe: { ok: true, bot: { username: "clawdbot_bot" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(lines.join("\n")).toMatch(/bot:@clawdbot_bot/);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
const lines = formatGatewayChannelsStatusLines({
|
||||||
channelAccounts: {
|
channelAccounts: {
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
|
|||||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||||
bits.push(`mode:${account.mode}`);
|
bits.push(`mode:${account.mode}`);
|
||||||
}
|
}
|
||||||
|
const botUsername = (() => {
|
||||||
|
const bot = account.bot as { username?: string | null } | undefined;
|
||||||
|
const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot;
|
||||||
|
const raw = bot?.username ?? probeBot?.username ?? "";
|
||||||
|
if (typeof raw !== "string") return "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
|
||||||
|
})();
|
||||||
|
if (botUsername) {
|
||||||
|
bits.push(`bot:${botUsername}`);
|
||||||
|
}
|
||||||
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
|
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
|
||||||
bits.push(`dm:${account.dmPolicy}`);
|
bits.push(`dm:${account.dmPolicy}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveGatewayPort } from "../config/config.js";
|
import { resolveGatewayPort } from "../config/config.js";
|
||||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
import {
|
||||||
|
resolveGatewayLaunchAgentLabel,
|
||||||
|
resolveNodeLaunchAgentLabel,
|
||||||
|
} from "../daemon/constants.js";
|
||||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||||
|
import {
|
||||||
|
isLaunchAgentListed,
|
||||||
|
isLaunchAgentLoaded,
|
||||||
|
launchAgentPlistExists,
|
||||||
|
repairLaunchAgentBootstrap,
|
||||||
|
} from "../daemon/launchd.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||||
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
|
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
|
||||||
@@ -21,6 +30,50 @@ import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
|||||||
import { healthCommand } from "./health.js";
|
import { healthCommand } from "./health.js";
|
||||||
import { formatHealthCheckFailure } from "./health-format.js";
|
import { formatHealthCheckFailure } from "./health-format.js";
|
||||||
|
|
||||||
|
async function maybeRepairLaunchAgentBootstrap(params: {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
title: string;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
prompter: DoctorPrompter;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (process.platform !== "darwin") return false;
|
||||||
|
|
||||||
|
const listed = await isLaunchAgentListed({ env: params.env });
|
||||||
|
if (!listed) return false;
|
||||||
|
|
||||||
|
const loaded = await isLaunchAgentLoaded({ env: params.env });
|
||||||
|
if (loaded) return false;
|
||||||
|
|
||||||
|
const plistExists = await launchAgentPlistExists(params.env);
|
||||||
|
if (!plistExists) return false;
|
||||||
|
|
||||||
|
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
|
||||||
|
|
||||||
|
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
|
||||||
|
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (!shouldFix) return false;
|
||||||
|
|
||||||
|
params.runtime.log(`Bootstrapping ${params.title} LaunchAgent...`);
|
||||||
|
const repair = await repairLaunchAgentBootstrap({ env: params.env });
|
||||||
|
if (!repair.ok) {
|
||||||
|
params.runtime.error(
|
||||||
|
`${params.title} LaunchAgent bootstrap failed: ${repair.detail ?? "unknown error"}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await isLaunchAgentLoaded({ env: params.env });
|
||||||
|
if (!verified) {
|
||||||
|
params.runtime.error(`${params.title} LaunchAgent still not loaded after repair.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
note(`${params.title} LaunchAgent repaired.`, `${params.title} LaunchAgent`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeRepairGatewayDaemon(params: {
|
export async function maybeRepairGatewayDaemon(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
@@ -32,12 +85,33 @@ export async function maybeRepairGatewayDaemon(params: {
|
|||||||
if (params.healthOk) return;
|
if (params.healthOk) return;
|
||||||
|
|
||||||
const service = resolveGatewayService();
|
const service = resolveGatewayService();
|
||||||
const loaded = await service.isLoaded({ env: process.env });
|
let loaded = await service.isLoaded({ env: process.env });
|
||||||
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
|
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
|
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === "darwin" && params.cfg.gateway?.mode !== "remote") {
|
||||||
|
const gatewayRepaired = await maybeRepairLaunchAgentBootstrap({
|
||||||
|
env: process.env,
|
||||||
|
title: "Gateway",
|
||||||
|
runtime: params.runtime,
|
||||||
|
prompter: params.prompter,
|
||||||
|
});
|
||||||
|
await maybeRepairLaunchAgentBootstrap({
|
||||||
|
env: { ...process.env, CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel() },
|
||||||
|
title: "Node",
|
||||||
|
runtime: params.runtime,
|
||||||
|
prompter: params.prompter,
|
||||||
|
});
|
||||||
|
if (gatewayRepaired) {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
if (loaded) {
|
||||||
|
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (params.cfg.gateway?.mode !== "remote") {
|
if (params.cfg.gateway?.mode !== "remote") {
|
||||||
const port = resolveGatewayPort(params.cfg, process.env);
|
const port = resolveGatewayPort(params.cfg, process.env);
|
||||||
const diagnostics = await inspectPortUsage(port);
|
const diagnostics = await inspectPortUsage(port);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
vi.mock("../config/sessions.js", () => ({
|
vi.mock("../config/sessions.js", () => ({
|
||||||
resolveStorePath: () => "/tmp/sessions.json",
|
resolveStorePath: () => "/tmp/sessions.json",
|
||||||
loadSessionStore: () => testStore,
|
loadSessionStore: () => testStore,
|
||||||
|
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||||
updateLastRoute: vi.fn().mockResolvedValue(undefined),
|
updateLastRoute: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { makePrompter } from "./onboarding/__tests__/test-utils.js";
|
||||||
|
import { promptDefaultModel } from "./model-picker.js";
|
||||||
|
|
||||||
|
const loadModelCatalog = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ensureAuthProfileStore = vi.hoisted(() =>
|
||||||
|
vi.fn(() => ({
|
||||||
|
version: 1,
|
||||||
|
profiles: {},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const listProfilesForProvider = vi.hoisted(() => vi.fn(() => []));
|
||||||
|
vi.mock("../agents/auth-profiles.js", () => ({
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
listProfilesForProvider,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined));
|
||||||
|
const getCustomProviderApiKey = vi.hoisted(() => vi.fn(() => undefined));
|
||||||
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
|
resolveEnvApiKey,
|
||||||
|
getCustomProviderApiKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("promptDefaultModel", () => {
|
||||||
|
it("filters internal router models from the selection list", async () => {
|
||||||
|
loadModelCatalog.mockResolvedValue([
|
||||||
|
{
|
||||||
|
provider: "openrouter",
|
||||||
|
id: "auto",
|
||||||
|
name: "OpenRouter Auto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "openrouter",
|
||||||
|
id: "meta-llama/llama-3.3-70b:free",
|
||||||
|
name: "Llama 3.3 70B",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const select = vi.fn(async (params) => {
|
||||||
|
const first = params.options[0];
|
||||||
|
return first?.value ?? "";
|
||||||
|
});
|
||||||
|
const prompter = makePrompter({ select });
|
||||||
|
const config = { agents: { defaults: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
await promptDefaultModel({
|
||||||
|
config,
|
||||||
|
prompter,
|
||||||
|
allowKeep: false,
|
||||||
|
includeManual: false,
|
||||||
|
ignoreAllowlist: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = select.mock.calls[0]?.[0]?.options ?? [];
|
||||||
|
expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false);
|
||||||
|
expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,11 @@ const KEEP_VALUE = "__keep__";
|
|||||||
const MANUAL_VALUE = "__manual__";
|
const MANUAL_VALUE = "__manual__";
|
||||||
const PROVIDER_FILTER_THRESHOLD = 30;
|
const PROVIDER_FILTER_THRESHOLD = 30;
|
||||||
|
|
||||||
|
// Models that are internal routing features and should not be shown in selection lists.
|
||||||
|
// These may be valid as defaults (e.g., set automatically during auth flow) but are not
|
||||||
|
// directly callable via API and would cause "Unknown model" errors if selected manually.
|
||||||
|
const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]);
|
||||||
|
|
||||||
type PromptDefaultModelParams = {
|
type PromptDefaultModelParams = {
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
@@ -183,6 +188,8 @@ export async function promptDefaultModel(
|
|||||||
}) => {
|
}) => {
|
||||||
const key = modelKey(entry.provider, entry.id);
|
const key = modelKey(entry.provider, entry.id);
|
||||||
if (seen.has(key)) return;
|
if (seen.has(key)) return;
|
||||||
|
// Skip internal router models that can't be directly called via API.
|
||||||
|
if (HIDDEN_ROUTER_MODELS.has(key)) return;
|
||||||
const hints: string[] = [];
|
const hints: string[] = [];
|
||||||
if (entry.name && entry.name !== entry.id) hints.push(entry.name);
|
if (entry.name && entry.name !== entry.id) hints.push(entry.name);
|
||||||
if (entry.contextWindow) hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
|
if (entry.contextWindow) hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
||||||
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
|
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
|
||||||
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
|
import type { ChannelMeta } from "../channels/plugins/types.js";
|
||||||
|
import {
|
||||||
|
formatChannelPrimerLine,
|
||||||
|
formatChannelSelectionLine,
|
||||||
|
listChatChannels,
|
||||||
|
} from "../channels/registry.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { isChannelConfigured } from "../config/plugin-auto-enable.js";
|
||||||
import type { DmPolicy } from "../config/types.js";
|
import type { DmPolicy } from "../config/types.js";
|
||||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||||
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
|
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
|
||||||
import type { ChannelChoice } from "./onboard-types.js";
|
import type { ChannelChoice } from "./onboard-types.js";
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +122,20 @@ async function collectChannelStatus(params: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry]));
|
||||||
|
const fallbackStatuses = listChatChannels()
|
||||||
|
.filter((meta) => !statusByChannel.has(meta.id))
|
||||||
|
.map((meta) => {
|
||||||
|
const configured = isChannelConfigured(params.cfg, meta.id);
|
||||||
|
const statusLabel = configured ? "configured (plugin disabled)" : "not configured";
|
||||||
|
return {
|
||||||
|
channel: meta.id,
|
||||||
|
configured,
|
||||||
|
statusLines: [`${meta.label}: ${statusLabel}`],
|
||||||
|
selectionHint: configured ? "configured · plugin disabled" : "not configured",
|
||||||
|
quickstartScore: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
const catalogStatuses = catalogEntries.map((entry) => ({
|
const catalogStatuses = catalogEntries.map((entry) => ({
|
||||||
channel: entry.id,
|
channel: entry.id,
|
||||||
configured: false,
|
configured: false,
|
||||||
@@ -122,13 +143,13 @@ async function collectChannelStatus(params: {
|
|||||||
selectionHint: "plugin · install",
|
selectionHint: "plugin · install",
|
||||||
quickstartScore: 0,
|
quickstartScore: 0,
|
||||||
}));
|
}));
|
||||||
const combinedStatuses = [...statusEntries, ...catalogStatuses];
|
const combinedStatuses = [...statusEntries, ...fallbackStatuses, ...catalogStatuses];
|
||||||
const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
|
const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry]));
|
||||||
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
|
const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines);
|
||||||
return {
|
return {
|
||||||
installedPlugins,
|
installedPlugins,
|
||||||
catalogEntries,
|
catalogEntries,
|
||||||
statusByChannel,
|
statusByChannel: mergedStatusByChannel,
|
||||||
statusLines,
|
statusLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -270,17 +291,28 @@ export async function setupChannels(
|
|||||||
});
|
});
|
||||||
if (!shouldConfigure) return cfg;
|
if (!shouldConfigure) return cfg;
|
||||||
|
|
||||||
|
const corePrimer = listChatChannels().map((meta) => ({
|
||||||
|
id: meta.id as ChannelChoice,
|
||||||
|
label: meta.label,
|
||||||
|
blurb: meta.blurb,
|
||||||
|
}));
|
||||||
|
const coreIds = new Set(corePrimer.map((entry) => entry.id));
|
||||||
const primerChannels = [
|
const primerChannels = [
|
||||||
...installedPlugins.map((plugin) => ({
|
...corePrimer,
|
||||||
id: plugin.id as ChannelChoice,
|
...installedPlugins
|
||||||
label: plugin.meta.label,
|
.filter((plugin) => !coreIds.has(plugin.id as ChannelChoice))
|
||||||
blurb: plugin.meta.blurb,
|
.map((plugin) => ({
|
||||||
})),
|
id: plugin.id as ChannelChoice,
|
||||||
...catalogEntries.map((entry) => ({
|
label: plugin.meta.label,
|
||||||
id: entry.id as ChannelChoice,
|
blurb: plugin.meta.blurb,
|
||||||
label: entry.meta.label,
|
})),
|
||||||
blurb: entry.meta.blurb,
|
...catalogEntries
|
||||||
})),
|
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id as ChannelChoice,
|
||||||
|
label: entry.meta.label,
|
||||||
|
blurb: entry.meta.blurb,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
await noteChannelPrimer(prompter, primerChannels);
|
await noteChannelPrimer(prompter, primerChannels);
|
||||||
|
|
||||||
@@ -301,7 +333,11 @@ export async function setupChannels(
|
|||||||
|
|
||||||
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
||||||
const plugin = getChannelPlugin(channel);
|
const plugin = getChannelPlugin(channel);
|
||||||
if (!plugin) return undefined;
|
if (!plugin) {
|
||||||
|
if (next.plugins?.entries?.[channel]?.enabled === false) return "plugin disabled";
|
||||||
|
if (next.plugins?.enabled === false) return "plugins disabled";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
|
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
|
||||||
const account = plugin.config.resolveAccount(next, accountId);
|
const account = plugin.config.resolveAccount(next, accountId);
|
||||||
let enabled: boolean | undefined;
|
let enabled: boolean | undefined;
|
||||||
@@ -336,21 +372,28 @@ export async function setupChannels(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getChannelEntries = () => {
|
const getChannelEntries = () => {
|
||||||
|
const core = listChatChannels();
|
||||||
const installed = listChannelPlugins();
|
const installed = listChannelPlugins();
|
||||||
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
||||||
const catalog = listChannelPluginCatalogEntries().filter(
|
const catalog = listChannelPluginCatalogEntries().filter(
|
||||||
(entry) => !installedIds.has(entry.id),
|
(entry) => !installedIds.has(entry.id),
|
||||||
);
|
);
|
||||||
const entries = [
|
const metaById = new Map<string, ChannelMeta>();
|
||||||
...installed.map((plugin) => ({
|
for (const meta of core) {
|
||||||
id: plugin.id as ChannelChoice,
|
metaById.set(meta.id, meta);
|
||||||
meta: plugin.meta,
|
}
|
||||||
})),
|
for (const plugin of installed) {
|
||||||
...catalog.map((entry) => ({
|
metaById.set(plugin.id, plugin.meta);
|
||||||
id: entry.id as ChannelChoice,
|
}
|
||||||
meta: entry.meta,
|
for (const entry of catalog) {
|
||||||
})),
|
if (!metaById.has(entry.id)) {
|
||||||
];
|
metaById.set(entry.id, entry.meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entries = Array.from(metaById, ([id, meta]) => ({
|
||||||
|
id: id as ChannelChoice,
|
||||||
|
meta,
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
entries,
|
entries,
|
||||||
catalog,
|
catalog,
|
||||||
@@ -365,6 +408,31 @@ export async function setupChannels(
|
|||||||
statusByChannel.set(channel, status);
|
statusByChannel.set(channel, status);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureBundledPluginEnabled = async (channel: ChannelChoice): Promise<boolean> => {
|
||||||
|
if (getChannelPlugin(channel)) return true;
|
||||||
|
const result = enablePluginInConfig(next, channel);
|
||||||
|
next = result.config;
|
||||||
|
if (!result.enabled) {
|
||||||
|
await prompter.note(
|
||||||
|
`Cannot enable ${channel}: ${result.reason ?? "plugin disabled"}.`,
|
||||||
|
"Channel setup",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||||
|
reloadOnboardingPluginRegistry({
|
||||||
|
cfg: next,
|
||||||
|
runtime,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
if (!getChannelPlugin(channel)) {
|
||||||
|
await prompter.note(`${channel} plugin not available.`, "Channel setup");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await refreshStatus(channel);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const configureChannel = async (channel: ChannelChoice) => {
|
const configureChannel = async (channel: ChannelChoice) => {
|
||||||
const adapter = getChannelOnboardingAdapter(channel);
|
const adapter = getChannelOnboardingAdapter(channel);
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
@@ -476,6 +544,9 @@ export async function setupChannels(
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
});
|
});
|
||||||
await refreshStatus(channel);
|
await refreshStatus(channel);
|
||||||
|
} else {
|
||||||
|
const enabled = await ensureBundledPluginEnabled(channel);
|
||||||
|
if (!enabled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = getChannelPlugin(channel);
|
const plugin = getChannelPlugin(channel);
|
||||||
@@ -531,10 +602,8 @@ export async function setupChannels(
|
|||||||
options?.onSelection?.(selection);
|
options?.onSelection?.(selection);
|
||||||
|
|
||||||
const selectionNotes = new Map<string, string>();
|
const selectionNotes = new Map<string, string>();
|
||||||
for (const plugin of listChannelPlugins()) {
|
const { entries: selectionEntries } = getChannelEntries();
|
||||||
selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink));
|
for (const entry of selectionEntries) {
|
||||||
}
|
|
||||||
for (const entry of listChannelPluginCatalogEntries()) {
|
|
||||||
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
||||||
}
|
}
|
||||||
const selectedLines = selection
|
const selectedLines = selection
|
||||||
|
|||||||
@@ -77,6 +77,39 @@ describe("onboard-hooks", () => {
|
|||||||
configChecks: [],
|
configChecks: [],
|
||||||
install: [],
|
install: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "command-logger",
|
||||||
|
description: "Log all command events to a centralized audit file",
|
||||||
|
source: "clawdbot-bundled",
|
||||||
|
pluginId: undefined,
|
||||||
|
filePath: "/mock/workspace/hooks/command-logger/HOOK.md",
|
||||||
|
baseDir: "/mock/workspace/hooks/command-logger",
|
||||||
|
handlerPath: "/mock/workspace/hooks/command-logger/handler.js",
|
||||||
|
hookKey: "command-logger",
|
||||||
|
emoji: "📝",
|
||||||
|
events: ["command"],
|
||||||
|
homepage: undefined,
|
||||||
|
always: false,
|
||||||
|
disabled: false,
|
||||||
|
eligible,
|
||||||
|
managedByPlugin: false,
|
||||||
|
requirements: {
|
||||||
|
bins: [],
|
||||||
|
anyBins: [],
|
||||||
|
env: [],
|
||||||
|
config: ["workspace.dir"],
|
||||||
|
os: [],
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
bins: [],
|
||||||
|
anyBins: [],
|
||||||
|
env: [],
|
||||||
|
config: eligible ? [] : ["workspace.dir"],
|
||||||
|
os: [],
|
||||||
|
},
|
||||||
|
configChecks: [],
|
||||||
|
install: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,6 +138,11 @@ describe("onboard-hooks", () => {
|
|||||||
label: "💾 session-memory",
|
label: "💾 session-memory",
|
||||||
hint: "Save session context to memory when /new command is issued",
|
hint: "Save session context to memory when /new command is issued",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "command-logger",
|
||||||
|
label: "📝 command-logger",
|
||||||
|
hint: "Log all command events to a centralized audit file",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export async function setupInternalHooks(
|
|||||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||||
const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg });
|
const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg });
|
||||||
|
|
||||||
// Filter for eligible and recommended hooks (session-memory is recommended)
|
// Show every eligible hook so users can opt in during onboarding.
|
||||||
const recommendedHooks = report.hooks.filter((h) => h.eligible && h.name === "session-memory");
|
const eligibleHooks = report.hooks.filter((h) => h.eligible);
|
||||||
|
|
||||||
if (recommendedHooks.length === 0) {
|
if (eligibleHooks.length === 0) {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
"No eligible hooks found. You can configure hooks later in your config.",
|
"No eligible hooks found. You can configure hooks later in your config.",
|
||||||
"No Hooks Available",
|
"No Hooks Available",
|
||||||
@@ -38,7 +38,7 @@ export async function setupInternalHooks(
|
|||||||
message: "Enable hooks?",
|
message: "Enable hooks?",
|
||||||
options: [
|
options: [
|
||||||
{ value: "__skip__", label: "Skip for now" },
|
{ value: "__skip__", label: "Skip for now" },
|
||||||
...recommendedHooks.map((hook) => ({
|
...eligibleHooks.map((hook) => ({
|
||||||
value: hook.name,
|
value: hook.name,
|
||||||
label: `${hook.emoji ?? "🔗"} ${hook.name}`,
|
label: `${hook.emoji ?? "🔗"} ${hook.name}`,
|
||||||
hint: hook.description,
|
hint: hook.description,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createServer } from "node:net";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
async function getFreePort(): Promise<number> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
@@ -50,7 +50,6 @@ describe("onboard (non-interactive): remote gateway config", () => {
|
|||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const token = "tok_remote_123";
|
const token = "tok_remote_123";
|
||||||
@@ -85,8 +84,8 @@ describe("onboard (non-interactive): remote gateway config", () => {
|
|||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
const { resolveConfigPath } = await import("../config/config.js");
|
||||||
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
||||||
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user