Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -32,12 +32,14 @@
|
|||||||
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
||||||
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
|
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
|
||||||
- Status: show active auth profile and key snippet in /status.
|
- Status: show active auth profile and key snippet in /status.
|
||||||
|
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
|
||||||
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
|
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
|
||||||
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
|
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
|
||||||
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
|
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
|
||||||
- Control UI: logs tab opens at the newest entries (bottom).
|
- Control UI: logs tab opens at the newest entries (bottom).
|
||||||
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
||||||
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
|
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
|
||||||
|
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
|
||||||
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
|
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
|
||||||
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
|
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
|
||||||
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
||||||
@@ -53,6 +55,8 @@
|
|||||||
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
||||||
- Daemon runtime: remove Bun from selection options.
|
- Daemon runtime: remove Bun from selection options.
|
||||||
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
||||||
|
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
|
||||||
|
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
|
||||||
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
|
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
|
||||||
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
|
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
|
||||||
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
|
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
|
||||||
@@ -78,6 +82,8 @@
|
|||||||
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
|
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
|
||||||
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
|
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
|
||||||
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
|
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
|
||||||
|
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
|
||||||
|
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
|
||||||
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
||||||
|
|
||||||
## 2026.1.8
|
## 2026.1.8
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 20260108
|
versionCode = 20260109
|
||||||
versionName = "2026.1.8"
|
versionName = "2026.1.9"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.8</string>
|
<string>2026.1.9</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260108</string>
|
<string>20260109</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.8</string>
|
<string>2026.1.9</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260108</string>
|
<string>20260109</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.8</string>
|
<string>2026.1.9</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260108</string>
|
<string>20260109</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -51,11 +51,16 @@ Notes:
|
|||||||
|
|
||||||
All query commands use WebSocket RPC.
|
All query commands use WebSocket RPC.
|
||||||
|
|
||||||
Shared options:
|
Output modes:
|
||||||
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
|
- Default: human-readable (colored in TTY).
|
||||||
- `--token <token>`: Gateway token (if required).
|
- `--json`: machine-readable JSON (no styling/spinner).
|
||||||
- `--password <password>`: Gateway password (password auth).
|
- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout.
|
||||||
- `--timeout <ms>`: timeout (default `10000`).
|
|
||||||
|
Shared options (where supported):
|
||||||
|
- `--url <url>`: Gateway WebSocket URL.
|
||||||
|
- `--token <token>`: Gateway token.
|
||||||
|
- `--password <password>`: Gateway password.
|
||||||
|
- `--timeout <ms>`: timeout/budget (varies per command).
|
||||||
- `--expect-final`: wait for a “final” response (agent calls).
|
- `--expect-final`: wait for a “final” response (agent calls).
|
||||||
|
|
||||||
### `gateway health`
|
### `gateway health`
|
||||||
@@ -66,8 +71,15 @@ clawdbot gateway health --url ws://127.0.0.1:18789
|
|||||||
|
|
||||||
### `gateway status`
|
### `gateway status`
|
||||||
|
|
||||||
|
`gateway status` is the “debug everything” command. It always probes:
|
||||||
|
- your configured remote gateway (if set), and
|
||||||
|
- localhost (loopback) **even if remote is configured**.
|
||||||
|
|
||||||
|
If multiple gateways are reachable, it prints all of them and warns this is an unconventional setup (usually you want only one gateway).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot gateway status --url ws://127.0.0.1:18789
|
clawdbot gateway status
|
||||||
|
clawdbot gateway status --json
|
||||||
```
|
```
|
||||||
|
|
||||||
### `gateway call <method>`
|
### `gateway call <method>`
|
||||||
@@ -104,4 +116,3 @@ Examples:
|
|||||||
clawdbot gateway discover --timeout 4000
|
clawdbot gateway discover --timeout 4000
|
||||||
clawdbot gateway discover --json | jq '.beacons[].wsUrl'
|
clawdbot gateway discover --json | jq '.beacons[].wsUrl'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
|
|
||||||
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
|
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
|
||||||
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
|
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
|
||||||
|
- `--no-color`: disable ANSI colors.
|
||||||
- `-V`, `--version`, `-v`: print version and exit.
|
- `-V`, `--version`, `-v`: print version and exit.
|
||||||
|
|
||||||
## Output styling
|
## Output styling
|
||||||
@@ -20,7 +21,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- ANSI colors and progress indicators only render in TTY sessions.
|
- ANSI colors and progress indicators only render in TTY sessions.
|
||||||
- OSC-8 hyperlinks render as clickable links in supported terminals; otherwise we fall back to plain URLs.
|
- OSC-8 hyperlinks render as clickable links in supported terminals; otherwise we fall back to plain URLs.
|
||||||
- `--json` (and `--plain` where supported) disables styling for clean output.
|
- `--json` (and `--plain` where supported) disables styling for clean output.
|
||||||
- `--no-color` disables ANSI styling where supported; `NO_COLOR=1` is also respected.
|
- `--no-color` disables ANSI styling; `NO_COLOR=1` is also respected.
|
||||||
- Long-running commands show a progress indicator (OSC 9;4 when supported).
|
- Long-running commands show a progress indicator (OSC 9;4 when supported).
|
||||||
|
|
||||||
## Color palette
|
## Color palette
|
||||||
@@ -168,8 +169,9 @@ Options:
|
|||||||
- `--workspace <dir>`
|
- `--workspace <dir>`
|
||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--mode <local|remote>`
|
- `--mode <local|remote>`
|
||||||
- `--auth-choice <oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
|
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
|
||||||
- `--anthropic-api-key <key>`
|
- `--anthropic-api-key <key>`
|
||||||
|
- `--openai-api-key <key>`
|
||||||
- `--gemini-api-key <key>`
|
- `--gemini-api-key <key>`
|
||||||
- `--gateway-port <port>`
|
- `--gateway-port <port>`
|
||||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||||
@@ -301,6 +303,10 @@ Subcommands:
|
|||||||
- `message voice status`
|
- `message voice status`
|
||||||
- `message event <list|create>`
|
- `message event <list|create>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `clawdbot message send --to +15555550123 --message "Hi"`
|
||||||
|
- `clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
|
||||||
|
|
||||||
### `agent`
|
### `agent`
|
||||||
Run one agent turn via the Gateway (or `--local` embedded).
|
Run one agent turn via the Gateway (or `--local` embedded).
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Runbook + exact service labels: [Gateway runbook](/gateway)
|
|||||||
Install a known-good version:
|
Install a known-good version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i -g clawdbot@2026.1.8
|
npm i -g clawdbot@2026.1.9
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart + re-run doctor:
|
Then restart + re-run doctor:
|
||||||
|
|||||||
@@ -29,17 +29,17 @@ Notes:
|
|||||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=0.1.0 \
|
APP_VERSION=2026.1.9 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||||
scripts/package-mac-app.sh
|
scripts/package-mac-app.sh
|
||||||
|
|
||||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-0.1.0.zip
|
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.9.zip
|
||||||
|
|
||||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-0.1.0.dmg
|
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.9.dmg
|
||||||
|
|
||||||
# Recommended: build + notarize/staple zip + DMG
|
# Recommended: build + notarize/staple zip + DMG
|
||||||
# First, create a keychain profile once:
|
# First, create a keychain profile once:
|
||||||
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-0.1.0.dmg
|
|||||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=0.1.0 \
|
APP_VERSION=2026.1.9 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||||
scripts/package-mac-dist.sh
|
scripts/package-mac-dist.sh
|
||||||
|
|
||||||
# Optional: ship dSYM alongside the release
|
# Optional: ship dSYM alongside the release
|
||||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-0.1.0.dSYM.zip
|
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.9.dSYM.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## Appcast entry
|
## Appcast entry
|
||||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||||
```bash
|
```bash
|
||||||
SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-0.1.0.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.9.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||||
```
|
```
|
||||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||||
|
|
||||||
## Publish & verify
|
## Publish & verify
|
||||||
- Upload `Clawdbot-0.1.0.zip` (and `Clawdbot-0.1.0.dSYM.zip`) to the GitHub release for tag `v0.1.0`.
|
- Upload `Clawdbot-2026.1.9.zip` (and `Clawdbot-2026.1.9.dSYM.zip`) to the GitHub release for tag `v2026.1.9`.
|
||||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||||
- Sanity checks:
|
- Sanity checks:
|
||||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||||
|
|||||||
@@ -71,11 +71,12 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
2) **Model/Auth**
|
2) **Model/Auth**
|
||||||
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
||||||
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||||
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
|
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
|
||||||
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||||
- **API key**: stores the key for you.
|
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
|
||||||
|
- **API key**: stores the key for you.
|
||||||
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
||||||
- **Skip**: no auth configured yet.
|
- **Skip**: no auth configured yet.
|
||||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.8-2",
|
"version": "2026.1.9",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -167,6 +167,117 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("promotes <think> tags to thinking blocks at write-time", () => {
|
||||||
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlockReply = vi.fn();
|
||||||
|
|
||||||
|
subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<
|
||||||
|
typeof subscribeEmbeddedPiSession
|
||||||
|
>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
onBlockReply,
|
||||||
|
blockReplyBreak: "message_end",
|
||||||
|
reasoningMode: "on",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as AssistantMessage;
|
||||||
|
|
||||||
|
handler?.({ type: "message_end", message: assistantMessage });
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onBlockReply.mock.calls[0][0].text).toBe(
|
||||||
|
"_Reasoning:_\n_Because it helps_\n\nFinal answer",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantMessage.content).toEqual([
|
||||||
|
{ type: "thinking", thinking: "Because it helps" },
|
||||||
|
{ type: "text", text: "Final answer" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams <think> reasoning via onReasoningStream without leaking into final text", () => {
|
||||||
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReasoningStream = vi.fn();
|
||||||
|
const onBlockReply = vi.fn();
|
||||||
|
|
||||||
|
subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<
|
||||||
|
typeof subscribeEmbeddedPiSession
|
||||||
|
>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
onReasoningStream,
|
||||||
|
onBlockReply,
|
||||||
|
blockReplyBreak: "message_end",
|
||||||
|
reasoningMode: "stream",
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "<think>\nBecause",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: " it helps\n</think>\n\nFinal answer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as AssistantMessage;
|
||||||
|
|
||||||
|
handler?.({ type: "message_end", message: assistantMessage });
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer");
|
||||||
|
|
||||||
|
const streamTexts = onReasoningStream.mock.calls
|
||||||
|
.map((call) => call[0]?.text)
|
||||||
|
.filter((value): value is string => typeof value === "string");
|
||||||
|
expect(streamTexts.at(-1)).toBe("Reasoning:\nBecause it helps");
|
||||||
|
|
||||||
|
expect(assistantMessage.content).toEqual([
|
||||||
|
{ type: "thinking", thinking: "Because it helps" },
|
||||||
|
{ type: "text", text: "Final answer" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits block replies on text_end and does not duplicate on message_end", () => {
|
it("emits block replies on text_end and does not duplicate on message_end", () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
|||||||
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||||
const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi;
|
const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi;
|
||||||
const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi;
|
const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi;
|
||||||
|
const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
|
||||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||||
const log = createSubsystemLogger("agent/embedded");
|
const log = createSubsystemLogger("agent/embedded");
|
||||||
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
|
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
|
||||||
@@ -121,6 +122,96 @@ function stripUnpairedThinkingTags(text: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThinkTaggedSplitBlock =
|
||||||
|
| { type: "thinking"; thinking: string }
|
||||||
|
| { type: "text"; text: string };
|
||||||
|
|
||||||
|
function splitThinkingTaggedText(text: string): ThinkTaggedSplitBlock[] | null {
|
||||||
|
const trimmedStart = text.trimStart();
|
||||||
|
// Avoid false positives: only treat it as structured thinking when it begins
|
||||||
|
// with a think tag (common for local/OpenAI-compat providers that emulate
|
||||||
|
// reasoning blocks via tags).
|
||||||
|
if (!trimmedStart.startsWith("<")) return null;
|
||||||
|
if (!THINKING_OPEN_RE.test(trimmedStart)) return null;
|
||||||
|
if (!THINKING_CLOSE_RE.test(text)) return null;
|
||||||
|
|
||||||
|
THINKING_TAG_SCAN_RE.lastIndex = 0;
|
||||||
|
let inThinking = false;
|
||||||
|
let cursor = 0;
|
||||||
|
let thinkingStart = 0;
|
||||||
|
const blocks: ThinkTaggedSplitBlock[] = [];
|
||||||
|
|
||||||
|
const pushText = (value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
blocks.push({ type: "text", text: value });
|
||||||
|
};
|
||||||
|
const pushThinking = (value: string) => {
|
||||||
|
const cleaned = value.trim();
|
||||||
|
if (!cleaned) return;
|
||||||
|
blocks.push({ type: "thinking", thinking: cleaned });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const match of text.matchAll(THINKING_TAG_SCAN_RE)) {
|
||||||
|
const index = match.index ?? 0;
|
||||||
|
const isClose = Boolean(match[1]?.includes("/"));
|
||||||
|
|
||||||
|
if (!inThinking && !isClose) {
|
||||||
|
pushText(text.slice(cursor, index));
|
||||||
|
thinkingStart = index + match[0].length;
|
||||||
|
inThinking = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inThinking && isClose) {
|
||||||
|
pushThinking(text.slice(thinkingStart, index));
|
||||||
|
cursor = index + match[0].length;
|
||||||
|
inThinking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inThinking) return null;
|
||||||
|
pushText(text.slice(cursor));
|
||||||
|
|
||||||
|
const hasThinking = blocks.some((b) => b.type === "thinking");
|
||||||
|
if (!hasThinking) return null;
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteThinkingTagsToBlocks(message: AssistantMessage): void {
|
||||||
|
if (!Array.isArray(message.content)) return;
|
||||||
|
const hasThinkingBlock = message.content.some(
|
||||||
|
(block) => block.type === "thinking",
|
||||||
|
);
|
||||||
|
if (hasThinkingBlock) return;
|
||||||
|
|
||||||
|
const next: AssistantMessage["content"] = [];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block.type !== "text") {
|
||||||
|
next.push(block);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const split = splitThinkingTaggedText(block.text);
|
||||||
|
if (!split) {
|
||||||
|
next.push(block);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
for (const part of split) {
|
||||||
|
if (part.type === "thinking") {
|
||||||
|
next.push({ type: "thinking", thinking: part.thinking });
|
||||||
|
} else if (part.type === "text") {
|
||||||
|
const cleaned = part.text.trimStart();
|
||||||
|
if (cleaned) next.push({ type: "text", text: cleaned });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
message.content = next;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSlackTarget(raw: string): string | undefined {
|
function normalizeSlackTarget(raw: string): string | undefined {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
@@ -792,6 +883,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
|
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
|
||||||
if (msg?.role === "assistant") {
|
if (msg?.role === "assistant") {
|
||||||
const assistantMessage = msg as AssistantMessage;
|
const assistantMessage = msg as AssistantMessage;
|
||||||
|
promoteThinkingTagsToBlocks(assistantMessage);
|
||||||
const rawText = extractAssistantText(assistantMessage);
|
const rawText = extractAssistantText(assistantMessage);
|
||||||
appendRawStream({
|
appendRawStream({
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const forceFreePortAndWait = vi.fn(async () => ({
|
|||||||
}));
|
}));
|
||||||
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
||||||
const discoverGatewayBeacons = vi.fn(async () => []);
|
const discoverGatewayBeacons = vi.fn(async () => []);
|
||||||
|
const gatewayStatusCommand = vi.fn(async () => {});
|
||||||
|
|
||||||
const runtimeLogs: string[] = [];
|
const runtimeLogs: string[] = [];
|
||||||
const runtimeErrors: string[] = [];
|
const runtimeErrors: string[] = [];
|
||||||
@@ -95,8 +96,12 @@ vi.mock("../infra/bonjour-discovery.js", () => ({
|
|||||||
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
|
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/gateway-status.js", () => ({
|
||||||
|
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("gateway-cli coverage", () => {
|
describe("gateway-cli coverage", () => {
|
||||||
it("registers call/health/status commands and routes to callGateway", async () => {
|
it("registers call/health commands and routes to callGateway", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
runtimeErrors.length = 0;
|
runtimeErrors.length = 0;
|
||||||
callGateway.mockClear();
|
callGateway.mockClear();
|
||||||
@@ -115,6 +120,21 @@ describe("gateway-cli coverage", () => {
|
|||||||
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers gateway status and routes to gatewayStatusCommand", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
gatewayStatusCommand.mockClear();
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["gateway", "status", "--json"], { from: "user" });
|
||||||
|
|
||||||
|
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers gateway discover and prints JSON", async () => {
|
it("registers gateway discover and prints JSON", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
runtimeErrors.length = 0;
|
runtimeErrors.length = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { gatewayStatusCommand } from "../commands/gateway-status.js";
|
||||||
import {
|
import {
|
||||||
CONFIG_PATH_CLAWDBOT,
|
CONFIG_PATH_CLAWDBOT,
|
||||||
type GatewayAuthMode,
|
type GatewayAuthMode,
|
||||||
@@ -42,6 +43,7 @@ type GatewayRpcOpts = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
expectFinal?: boolean;
|
expectFinal?: boolean;
|
||||||
|
json?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GatewayRunOpts = {
|
type GatewayRunOpts = {
|
||||||
@@ -369,7 +371,8 @@ const gatewayCallOpts = (cmd: Command) =>
|
|||||||
.option("--token <token>", "Gateway token (if required)")
|
.option("--token <token>", "Gateway token (if required)")
|
||||||
.option("--password <password>", "Gateway password (password auth)")
|
.option("--password <password>", "Gateway password (password auth)")
|
||||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||||
.option("--expect-final", "Wait for final response (agent)", false);
|
.option("--expect-final", "Wait for final response (agent)", false)
|
||||||
|
.option("--json", "Output JSON", false);
|
||||||
|
|
||||||
const callGatewayCli = async (
|
const callGatewayCli = async (
|
||||||
method: string,
|
method: string,
|
||||||
@@ -380,7 +383,7 @@ const callGatewayCli = async (
|
|||||||
{
|
{
|
||||||
label: `Gateway ${method}`,
|
label: `Gateway ${method}`,
|
||||||
indeterminate: true,
|
indeterminate: true,
|
||||||
enabled: true,
|
enabled: opts.json !== true,
|
||||||
},
|
},
|
||||||
async () =>
|
async () =>
|
||||||
await callGateway({
|
await callGateway({
|
||||||
@@ -729,7 +732,7 @@ export function registerGatewayCli(program: Command) {
|
|||||||
gatewayCallOpts(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("call")
|
.command("call")
|
||||||
.description("Call a Gateway method and print JSON")
|
.description("Call a Gateway method")
|
||||||
.argument(
|
.argument(
|
||||||
"<method>",
|
"<method>",
|
||||||
"Method name (health/status/system-presence/cron.*)",
|
"Method name (health/status/system-presence/cron.*)",
|
||||||
@@ -739,6 +742,18 @@ export function registerGatewayCli(program: Command) {
|
|||||||
try {
|
try {
|
||||||
const params = JSON.parse(String(opts.params ?? "{}"));
|
const params = JSON.parse(String(opts.params ?? "{}"));
|
||||||
const result = await callGatewayCli(method, opts, params);
|
const result = await callGatewayCli(method, opts, params);
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rich = isRich();
|
||||||
|
defaultRuntime.log(
|
||||||
|
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(
|
||||||
|
rich,
|
||||||
|
theme.muted,
|
||||||
|
String(method),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
|
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
|
||||||
@@ -754,7 +769,46 @@ export function registerGatewayCli(program: Command) {
|
|||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
try {
|
try {
|
||||||
const result = await callGatewayCli("health", opts);
|
const result = await callGatewayCli("health", opts);
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rich = isRich();
|
||||||
|
const obj =
|
||||||
|
result && typeof result === "object"
|
||||||
|
? (result as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const durationMs =
|
||||||
|
typeof obj.durationMs === "number" ? obj.durationMs : null;
|
||||||
|
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
|
||||||
|
defaultRuntime.log(
|
||||||
|
`${colorize(rich, theme.success, "OK")}${
|
||||||
|
durationMs != null ? ` (${durationMs}ms)` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
if (obj.web && typeof obj.web === "object") {
|
||||||
|
const web = obj.web as Record<string, unknown>;
|
||||||
|
const linked = web.linked === true;
|
||||||
|
defaultRuntime.log(
|
||||||
|
`Web: ${linked ? "linked" : "not linked"}${
|
||||||
|
typeof web.authAgeMs === "number" && linked
|
||||||
|
? ` (${Math.round(web.authAgeMs / 60_000)}m)`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (obj.telegram && typeof obj.telegram === "object") {
|
||||||
|
const tg = obj.telegram as Record<string, unknown>;
|
||||||
|
defaultRuntime.log(
|
||||||
|
`Telegram: ${tg.configured === true ? "configured" : "not configured"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (obj.discord && typeof obj.discord === "object") {
|
||||||
|
const dc = obj.discord as Record<string, unknown>;
|
||||||
|
defaultRuntime.log(
|
||||||
|
`Discord: ${dc.configured === true ? "configured" : "not configured"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@@ -762,20 +816,27 @@ export function registerGatewayCli(program: Command) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
gatewayCallOpts(
|
gateway
|
||||||
gateway
|
.command("status")
|
||||||
.command("status")
|
.description(
|
||||||
.description("Fetch Gateway status")
|
"Show gateway reachability + discovery + health + status summary (local + remote)",
|
||||||
.action(async (opts) => {
|
)
|
||||||
try {
|
.option(
|
||||||
const result = await callGatewayCli("status", opts);
|
"--url <url>",
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
"Explicit Gateway WebSocket URL (still probes localhost)",
|
||||||
} catch (err) {
|
)
|
||||||
defaultRuntime.error(String(err));
|
.option("--token <token>", "Gateway token (applies to all probes)")
|
||||||
defaultRuntime.exit(1);
|
.option("--password <password>", "Gateway password (applies to all probes)")
|
||||||
}
|
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
|
||||||
}),
|
.option("--json", "Output JSON", false)
|
||||||
);
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
await gatewayStatusCommand(opts, defaultRuntime);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
gateway
|
gateway
|
||||||
.command("discover")
|
.command("discover")
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export function buildProgram() {
|
|||||||
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
|
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
program.option("--no-color", "Disable ANSI colors", false);
|
||||||
|
|
||||||
program.configureHelp({
|
program.configureHelp({
|
||||||
optionTerm: (option) => theme.option(option.flags),
|
optionTerm: (option) => theme.option(option.flags),
|
||||||
subcommandTerm: (cmd) => theme.command(cmd.name()),
|
subcommandTerm: (cmd) => theme.command(cmd.name()),
|
||||||
@@ -237,9 +239,10 @@ export function buildProgram() {
|
|||||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||||
.option(
|
.option(
|
||||||
"--auth-choice <choice>",
|
"--auth-choice <choice>",
|
||||||
"Auth: oauth|claude-cli|token|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
|
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
|
||||||
)
|
)
|
||||||
.option("--anthropic-api-key <key>", "Anthropic API key")
|
.option("--anthropic-api-key <key>", "Anthropic API key")
|
||||||
|
.option("--openai-api-key <key>", "OpenAI API key")
|
||||||
.option("--gemini-api-key <key>", "Gemini API key")
|
.option("--gemini-api-key <key>", "Gemini API key")
|
||||||
.option("--gateway-port <port>", "Gateway port")
|
.option("--gateway-port <port>", "Gateway port")
|
||||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
||||||
@@ -268,6 +271,7 @@ export function buildProgram() {
|
|||||||
| "claude-cli"
|
| "claude-cli"
|
||||||
| "token"
|
| "token"
|
||||||
| "openai-codex"
|
| "openai-codex"
|
||||||
|
| "openai-api-key"
|
||||||
| "codex-cli"
|
| "codex-cli"
|
||||||
| "antigravity"
|
| "antigravity"
|
||||||
| "gemini-api-key"
|
| "gemini-api-key"
|
||||||
@@ -276,6 +280,7 @@ export function buildProgram() {
|
|||||||
| "skip"
|
| "skip"
|
||||||
| undefined,
|
| undefined,
|
||||||
anthropicApiKey: opts.anthropicApiKey as string | undefined,
|
anthropicApiKey: opts.anthropicApiKey as string | undefined,
|
||||||
|
openaiApiKey: opts.openaiApiKey as string | undefined,
|
||||||
geminiApiKey: opts.geminiApiKey as string | undefined,
|
geminiApiKey: opts.geminiApiKey as string | undefined,
|
||||||
gatewayPort:
|
gatewayPort:
|
||||||
typeof opts.gatewayPort === "string"
|
typeof opts.gatewayPort === "string"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
value: "openai-codex",
|
value: "openai-codex",
|
||||||
label: "OpenAI Codex (ChatGPT OAuth)",
|
label: "OpenAI Codex (ChatGPT OAuth)",
|
||||||
});
|
});
|
||||||
|
options.push({ value: "openai-api-key", label: "OpenAI API key" });
|
||||||
options.push({
|
options.push({
|
||||||
value: "antigravity",
|
value: "antigravity",
|
||||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
} from "../agents/model-auth.js";
|
} from "../agents/model-auth.js";
|
||||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import {
|
import {
|
||||||
@@ -210,38 +210,10 @@ export async function applyAuthChoice(params: {
|
|||||||
mode: "token",
|
mode: "token",
|
||||||
});
|
});
|
||||||
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
|
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
|
||||||
const profileNameRaw = await params.prompter.text({
|
|
||||||
message: "Token name (blank = default)",
|
|
||||||
placeholder: "default",
|
|
||||||
});
|
|
||||||
const provider = (await params.prompter.select({
|
const provider = (await params.prompter.select({
|
||||||
message: "Token provider",
|
message: "Token provider",
|
||||||
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
|
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
|
||||||
})) as "anthropic";
|
})) as "anthropic";
|
||||||
const profileId = buildTokenProfileId({
|
|
||||||
provider,
|
|
||||||
name: String(profileNameRaw ?? ""),
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = ensureAuthProfileStore(params.agentDir, {
|
|
||||||
allowKeychainPrompt: false,
|
|
||||||
});
|
|
||||||
const existing = store.profiles[profileId];
|
|
||||||
if (existing?.type === "token") {
|
|
||||||
const useExisting = await params.prompter.confirm({
|
|
||||||
message: `Use existing token "${profileId}"?`,
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
if (useExisting) {
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
|
||||||
profileId,
|
|
||||||
provider,
|
|
||||||
mode: "token",
|
|
||||||
});
|
|
||||||
return { config: nextConfig, agentModelOverride };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await params.prompter.note(
|
await params.prompter.note(
|
||||||
[
|
[
|
||||||
"Run `claude setup-token` in your terminal.",
|
"Run `claude setup-token` in your terminal.",
|
||||||
@@ -256,46 +228,67 @@ export async function applyAuthChoice(params: {
|
|||||||
});
|
});
|
||||||
const token = String(tokenRaw).trim();
|
const token = String(tokenRaw).trim();
|
||||||
|
|
||||||
const wantsExpiry = await params.prompter.confirm({
|
const profileNameRaw = await params.prompter.text({
|
||||||
message: "Does this token expire?",
|
message: "Token name (blank = default)",
|
||||||
initialValue: false,
|
placeholder: "default",
|
||||||
|
});
|
||||||
|
const namedProfileId = buildTokenProfileId({
|
||||||
|
provider,
|
||||||
|
name: String(profileNameRaw ?? ""),
|
||||||
});
|
});
|
||||||
const expiresInRaw = wantsExpiry
|
|
||||||
? await params.prompter.text({
|
|
||||||
message: "Expires in (duration)",
|
|
||||||
initialValue: "365d",
|
|
||||||
validate: (value) => {
|
|
||||||
try {
|
|
||||||
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
|
|
||||||
return undefined;
|
|
||||||
} catch {
|
|
||||||
return "Invalid duration (e.g. 365d, 12h, 30m)";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const expiresIn = String(expiresInRaw).trim();
|
|
||||||
const expires = expiresIn
|
|
||||||
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
upsertAuthProfile({
|
upsertAuthProfile({
|
||||||
profileId,
|
profileId: namedProfileId,
|
||||||
agentDir: params.agentDir,
|
agentDir: params.agentDir,
|
||||||
credential: {
|
credential: {
|
||||||
type: "token",
|
type: "token",
|
||||||
provider,
|
provider,
|
||||||
token,
|
token,
|
||||||
...(expires ? { expires } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId,
|
profileId: namedProfileId,
|
||||||
provider,
|
provider,
|
||||||
mode: "token",
|
mode: "token",
|
||||||
});
|
});
|
||||||
|
} else if (params.authChoice === "openai-api-key") {
|
||||||
|
const envKey = resolveEnvApiKey("openai");
|
||||||
|
if (envKey) {
|
||||||
|
const useExisting = await params.prompter.confirm({
|
||||||
|
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (useExisting) {
|
||||||
|
const result = upsertSharedEnvVar({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: envKey.apiKey,
|
||||||
|
});
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
process.env.OPENAI_API_KEY = envKey.apiKey;
|
||||||
|
}
|
||||||
|
await params.prompter.note(
|
||||||
|
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||||
|
"OpenAI API key",
|
||||||
|
);
|
||||||
|
return { config: nextConfig, agentModelOverride };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await params.prompter.text({
|
||||||
|
message: "Enter OpenAI API key",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
const trimmed = String(key).trim();
|
||||||
|
const result = upsertSharedEnvVar({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: trimmed,
|
||||||
|
});
|
||||||
|
process.env.OPENAI_API_KEY = trimmed;
|
||||||
|
await params.prompter.note(
|
||||||
|
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||||
|
"OpenAI API key",
|
||||||
|
);
|
||||||
} else if (params.authChoice === "openai-codex") {
|
} else if (params.authChoice === "openai-codex") {
|
||||||
const isRemote = isRemoteEnvironment();
|
const isRemote = isRemoteEnvironment();
|
||||||
await params.prompter.note(
|
await params.prompter.note(
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
note as clackNote,
|
note as clackNote,
|
||||||
outro as clackOutro,
|
outro as clackOutro,
|
||||||
select as clackSelect,
|
select as clackSelect,
|
||||||
spinner,
|
|
||||||
text as clackText,
|
text as clackText,
|
||||||
|
spinner,
|
||||||
} from "@clack/prompts";
|
} from "@clack/prompts";
|
||||||
import {
|
import {
|
||||||
loginOpenAICodex,
|
loginOpenAICodex,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
upsertAuthProfile,
|
upsertAuthProfile,
|
||||||
} from "../agents/auth-profiles.js";
|
} from "../agents/auth-profiles.js";
|
||||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||||
import { createCliProgress } from "../cli/progress.js";
|
import { createCliProgress } from "../cli/progress.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,14 +36,15 @@ import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
|||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||||
|
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
|
||||||
import {
|
import {
|
||||||
stylePromptHint,
|
stylePromptHint,
|
||||||
stylePromptMessage,
|
stylePromptMessage,
|
||||||
stylePromptTitle,
|
stylePromptTitle,
|
||||||
} from "../terminal/prompt-style.js";
|
} from "../terminal/prompt-style.js";
|
||||||
|
import { theme } from "../terminal/theme.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||||
import {
|
import {
|
||||||
@@ -351,6 +352,7 @@ async function promptAuthConfig(
|
|||||||
| "claude-cli"
|
| "claude-cli"
|
||||||
| "token"
|
| "token"
|
||||||
| "openai-codex"
|
| "openai-codex"
|
||||||
|
| "openai-api-key"
|
||||||
| "codex-cli"
|
| "codex-cli"
|
||||||
| "antigravity"
|
| "antigravity"
|
||||||
| "gemini-api-key"
|
| "gemini-api-key"
|
||||||
@@ -398,14 +400,6 @@ async function promptAuthConfig(
|
|||||||
mode: "token",
|
mode: "token",
|
||||||
});
|
});
|
||||||
} else if (authChoice === "token" || authChoice === "oauth") {
|
} else if (authChoice === "token" || authChoice === "oauth") {
|
||||||
const profileNameRaw = guardCancel(
|
|
||||||
await text({
|
|
||||||
message: "Token name (blank = default)",
|
|
||||||
placeholder: "default",
|
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const provider = guardCancel(
|
const provider = guardCancel(
|
||||||
await select({
|
await select({
|
||||||
message: "Token provider",
|
message: "Token provider",
|
||||||
@@ -419,32 +413,6 @@ async function promptAuthConfig(
|
|||||||
runtime,
|
runtime,
|
||||||
) as "anthropic";
|
) as "anthropic";
|
||||||
|
|
||||||
const profileId = buildTokenProfileId({
|
|
||||||
provider,
|
|
||||||
name: String(profileNameRaw ?? ""),
|
|
||||||
});
|
|
||||||
const store = ensureAuthProfileStore(undefined, {
|
|
||||||
allowKeychainPrompt: false,
|
|
||||||
});
|
|
||||||
const existing = store.profiles[profileId];
|
|
||||||
if (existing?.type === "token") {
|
|
||||||
const useExisting = guardCancel(
|
|
||||||
await confirm({
|
|
||||||
message: `Use existing token "${profileId}"?`,
|
|
||||||
initialValue: true,
|
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (useExisting) {
|
|
||||||
next = applyAuthProfileConfig(next, {
|
|
||||||
profileId,
|
|
||||||
provider,
|
|
||||||
mode: "token",
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"Run `claude setup-token` in your terminal.",
|
"Run `claude setup-token` in your terminal.",
|
||||||
@@ -462,34 +430,17 @@ async function promptAuthConfig(
|
|||||||
);
|
);
|
||||||
const token = String(tokenRaw).trim();
|
const token = String(tokenRaw).trim();
|
||||||
|
|
||||||
const wantsExpiry = guardCancel(
|
const profileNameRaw = guardCancel(
|
||||||
await confirm({
|
await text({
|
||||||
message: "Does this token expire?",
|
message: "Token name (blank = default)",
|
||||||
initialValue: false,
|
placeholder: "default",
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
const expiresInRaw = wantsExpiry
|
const profileId = buildTokenProfileId({
|
||||||
? guardCancel(
|
provider,
|
||||||
await text({
|
name: String(profileNameRaw ?? ""),
|
||||||
message: "Expires in (duration)",
|
});
|
||||||
initialValue: "365d",
|
|
||||||
validate: (value) => {
|
|
||||||
try {
|
|
||||||
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
|
|
||||||
return undefined;
|
|
||||||
} catch {
|
|
||||||
return "Invalid duration (e.g. 365d, 12h, 30m)";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
const expiresIn = String(expiresInRaw).trim();
|
|
||||||
const expires = expiresIn
|
|
||||||
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
upsertAuthProfile({
|
upsertAuthProfile({
|
||||||
profileId,
|
profileId,
|
||||||
@@ -497,11 +448,52 @@ async function promptAuthConfig(
|
|||||||
type: "token",
|
type: "token",
|
||||||
provider,
|
provider,
|
||||||
token,
|
token,
|
||||||
...(expires ? { expires } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
|
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
|
||||||
|
} else if (authChoice === "openai-api-key") {
|
||||||
|
const envKey = resolveEnvApiKey("openai");
|
||||||
|
if (envKey) {
|
||||||
|
const useExisting = guardCancel(
|
||||||
|
await confirm({
|
||||||
|
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
if (useExisting) {
|
||||||
|
const result = upsertSharedEnvVar({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: envKey.apiKey,
|
||||||
|
});
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
process.env.OPENAI_API_KEY = envKey.apiKey;
|
||||||
|
}
|
||||||
|
note(
|
||||||
|
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||||
|
"OpenAI API key",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = guardCancel(
|
||||||
|
await text({
|
||||||
|
message: "Enter OpenAI API key",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
const trimmed = String(key).trim();
|
||||||
|
const result = upsertSharedEnvVar({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: trimmed,
|
||||||
|
});
|
||||||
|
process.env.OPENAI_API_KEY = trimmed;
|
||||||
|
note(
|
||||||
|
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||||
|
"OpenAI API key",
|
||||||
|
);
|
||||||
} else if (authChoice === "openai-codex") {
|
} else if (authChoice === "openai-codex") {
|
||||||
const isRemote = isRemoteEnvironment();
|
const isRemote = isRemoteEnvironment();
|
||||||
note(
|
note(
|
||||||
@@ -703,13 +695,24 @@ async function promptAuthConfig(
|
|||||||
next = applyMinimaxConfig(next);
|
next = applyMinimaxConfig(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentModel =
|
||||||
|
typeof next.agent?.model === "string"
|
||||||
|
? next.agent?.model
|
||||||
|
: (next.agent?.model?.primary ?? "");
|
||||||
|
const preferAnthropic =
|
||||||
|
authChoice === "claude-cli" ||
|
||||||
|
authChoice === "token" ||
|
||||||
|
authChoice === "oauth" ||
|
||||||
|
authChoice === "apiKey";
|
||||||
|
const modelInitialValue =
|
||||||
|
preferAnthropic && !currentModel.startsWith("anthropic/")
|
||||||
|
? "anthropic/claude-opus-4-5"
|
||||||
|
: currentModel;
|
||||||
|
|
||||||
const modelInput = guardCancel(
|
const modelInput = guardCancel(
|
||||||
await text({
|
await text({
|
||||||
message: "Default model (blank to keep)",
|
message: "Default model (blank to keep)",
|
||||||
initialValue:
|
initialValue: modelInitialValue,
|
||||||
typeof next.agent?.model === "string"
|
|
||||||
? next.agent?.model
|
|
||||||
: (next.agent?.model?.primary ?? ""),
|
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
@@ -1078,58 +1081,65 @@ export async function runConfigureWizard(
|
|||||||
runtime.error(controlUiAssets.message);
|
runtime.error(controlUiAssets.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||||
|
const links = resolveControlUiLinks({
|
||||||
|
bind,
|
||||||
|
port: gatewayPort,
|
||||||
|
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||||
|
});
|
||||||
|
const gatewayProbe = await probeGatewayReachable({
|
||||||
|
url: links.wsUrl,
|
||||||
|
token:
|
||||||
|
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
|
password:
|
||||||
|
nextConfig.gateway?.auth?.password ??
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||||
|
});
|
||||||
|
const gatewayStatusLine = gatewayProbe.ok
|
||||||
|
? "Gateway: reachable"
|
||||||
|
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||||
|
|
||||||
note(
|
note(
|
||||||
(() => {
|
[
|
||||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
`Web UI: ${links.httpUrl}`,
|
||||||
const links = resolveControlUiLinks({
|
`Gateway WS: ${links.wsUrl}`,
|
||||||
bind,
|
gatewayStatusLine,
|
||||||
port: gatewayPort,
|
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
].join("\n"),
|
||||||
});
|
|
||||||
return [
|
|
||||||
`Web UI: ${links.httpUrl}`,
|
|
||||||
`Gateway WS: ${links.wsUrl}`,
|
|
||||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
|
||||||
].join("\n");
|
|
||||||
})(),
|
|
||||||
"Control UI",
|
"Control UI",
|
||||||
);
|
);
|
||||||
|
|
||||||
const browserSupport = await detectBrowserOpenSupport();
|
const browserSupport = await detectBrowserOpenSupport();
|
||||||
if (!browserSupport.ok) {
|
if (gatewayProbe.ok) {
|
||||||
note(
|
if (!browserSupport.ok) {
|
||||||
formatControlUiSshHint({
|
note(
|
||||||
port: gatewayPort,
|
formatControlUiSshHint({
|
||||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
port: gatewayPort,
|
||||||
token: gatewayToken,
|
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||||
}),
|
token: gatewayToken,
|
||||||
"Open Control UI",
|
}),
|
||||||
);
|
"Open Control UI",
|
||||||
} else {
|
);
|
||||||
const wantsOpen = guardCancel(
|
} else {
|
||||||
await confirm({
|
const wantsOpen = guardCancel(
|
||||||
message: "Open Control UI now?",
|
await confirm({
|
||||||
initialValue: false,
|
message: "Open Control UI now?",
|
||||||
}),
|
initialValue: false,
|
||||||
runtime,
|
}),
|
||||||
);
|
runtime,
|
||||||
if (wantsOpen) {
|
);
|
||||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
if (wantsOpen) {
|
||||||
const links = resolveControlUiLinks({
|
const opened = await openUrl(links.httpUrl);
|
||||||
bind,
|
if (!opened) {
|
||||||
port: gatewayPort,
|
note(
|
||||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
formatControlUiSshHint({
|
||||||
});
|
port: gatewayPort,
|
||||||
const opened = await openUrl(links.httpUrl);
|
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||||
if (!opened) {
|
token: gatewayToken,
|
||||||
note(
|
}),
|
||||||
formatControlUiSshHint({
|
"Open Control UI",
|
||||||
port: gatewayPort,
|
);
|
||||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
}
|
||||||
token: gatewayToken,
|
|
||||||
}),
|
|
||||||
"Open Control UI",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ import {
|
|||||||
} from "../daemon/service-audit.js";
|
} from "../daemon/service-audit.js";
|
||||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||||
type GatewayDaemonRuntime,
|
type GatewayDaemonRuntime,
|
||||||
} from "./daemon-runtime.js";
|
} from "./daemon-runtime.js";
|
||||||
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
|
||||||
|
|
||||||
const note = (message: string, title?: string) =>
|
const note = (message: string, title?: string) =>
|
||||||
clackNote(message, stylePromptTitle(title));
|
clackNote(message, stylePromptTitle(title));
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { confirm, select } from "@clack/prompts";
|
import { confirm, select } from "@clack/prompts";
|
||||||
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
|
import {
|
||||||
|
stylePromptHint,
|
||||||
|
stylePromptMessage,
|
||||||
|
} from "../terminal/prompt-style.js";
|
||||||
import { guardCancel } from "./onboard-helpers.js";
|
import { guardCancel } from "./onboard-helpers.js";
|
||||||
|
|
||||||
export type DoctorOptions = {
|
export type DoctorOptions = {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { note as clackNote } from "@clack/prompts";
|
import { note as clackNote } from "@clack/prompts";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
|
||||||
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
|
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
||||||
import { resolveTelegramToken } from "../telegram/token.js";
|
import { resolveTelegramToken } from "../telegram/token.js";
|
||||||
|
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
|
|
||||||
const note = (message: string, title?: string) =>
|
const note = (message: string, title?: string) =>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
resolveSessionTranscriptsDirForAgent,
|
resolveSessionTranscriptsDirForAgent,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
|
||||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
|
|
||||||
const note = (message: string, title?: string) =>
|
const note = (message: string, title?: string) =>
|
||||||
clackNote(message, stylePromptTitle(title));
|
clackNote(message, stylePromptTitle(title));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { intro as clackIntro, note as clackNote, outro as clackOutro } from "@clack/prompts";
|
import {
|
||||||
|
intro as clackIntro,
|
||||||
|
note as clackNote,
|
||||||
|
outro as clackOutro,
|
||||||
|
} from "@clack/prompts";
|
||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
131
src/commands/gateway-status.test.ts
Normal file
131
src/commands/gateway-status.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const loadConfig = vi.fn(() => ({
|
||||||
|
gateway: {
|
||||||
|
mode: "remote",
|
||||||
|
remote: { url: "ws://remote.example:18789", token: "rtok" },
|
||||||
|
auth: { token: "ltok" },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const resolveGatewayPort = vi.fn(() => 18789);
|
||||||
|
const discoverGatewayBeacons = vi.fn(async () => []);
|
||||||
|
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
||||||
|
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
|
||||||
|
if (url.includes("127.0.0.1")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
connectLatencyMs: 12,
|
||||||
|
error: null,
|
||||||
|
close: null,
|
||||||
|
health: { ok: true },
|
||||||
|
status: { web: { linked: false }, sessions: { count: 0 } },
|
||||||
|
presence: [
|
||||||
|
{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" },
|
||||||
|
],
|
||||||
|
configSnapshot: {
|
||||||
|
path: "/tmp/cfg.json",
|
||||||
|
exists: true,
|
||||||
|
valid: true,
|
||||||
|
config: {
|
||||||
|
gateway: { mode: "local" },
|
||||||
|
bridge: { enabled: true, port: 18790 },
|
||||||
|
},
|
||||||
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
connectLatencyMs: 34,
|
||||||
|
error: null,
|
||||||
|
close: null,
|
||||||
|
health: { ok: true },
|
||||||
|
status: { web: { linked: true }, sessions: { count: 2 } },
|
||||||
|
presence: [
|
||||||
|
{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" },
|
||||||
|
],
|
||||||
|
configSnapshot: {
|
||||||
|
path: "/tmp/remote.json",
|
||||||
|
exists: true,
|
||||||
|
valid: true,
|
||||||
|
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
|
||||||
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
loadConfig: () => loadConfig(),
|
||||||
|
resolveGatewayPort: (cfg: unknown) => resolveGatewayPort(cfg),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/bonjour-discovery.js", () => ({
|
||||||
|
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/tailnet.js", () => ({
|
||||||
|
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../gateway/probe.js", () => ({
|
||||||
|
probeGateway: (opts: unknown) => probeGateway(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("gateway-status command", () => {
|
||||||
|
it("prints human output by default", async () => {
|
||||||
|
const runtimeLogs: string[] = [];
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
const runtime = {
|
||||||
|
log: (msg: string) => runtimeLogs.push(msg),
|
||||||
|
error: (msg: string) => runtimeErrors.push(msg),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`__exit__:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
||||||
|
await gatewayStatusCommand(
|
||||||
|
{ timeout: "1000" },
|
||||||
|
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
|
expect(runtimeLogs.join("\n")).toContain("Gateway Status");
|
||||||
|
expect(runtimeLogs.join("\n")).toContain("Discovery (this machine)");
|
||||||
|
expect(runtimeLogs.join("\n")).toContain("Targets");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints a structured JSON envelope when --json is set", async () => {
|
||||||
|
const runtimeLogs: string[] = [];
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
const runtime = {
|
||||||
|
log: (msg: string) => runtimeLogs.push(msg),
|
||||||
|
error: (msg: string) => runtimeErrors.push(msg),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`__exit__:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
||||||
|
await gatewayStatusCommand(
|
||||||
|
{ timeout: "1000", json: true },
|
||||||
|
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
|
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(parsed.ok).toBe(true);
|
||||||
|
expect(parsed.targets).toBeTruthy();
|
||||||
|
const targets = parsed.targets as Array<Record<string, unknown>>;
|
||||||
|
expect(targets.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(targets[0]?.health).toBeTruthy();
|
||||||
|
expect(targets[0]?.summary).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
523
src/commands/gateway-status.ts
Normal file
523
src/commands/gateway-status.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import { withProgress } from "../cli/progress.js";
|
||||||
|
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
||||||
|
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
|
||||||
|
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
|
||||||
|
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||||
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
|
type TargetKind = "explicit" | "configRemote" | "localLoopback";
|
||||||
|
|
||||||
|
type GatewayStatusTarget = {
|
||||||
|
id: string;
|
||||||
|
kind: TargetKind;
|
||||||
|
url: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GatewayConfigSummary = {
|
||||||
|
path: string | null;
|
||||||
|
exists: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
issues: Array<{ path: string; message: string }>;
|
||||||
|
legacyIssues: Array<{ path: string; message: string }>;
|
||||||
|
gateway: {
|
||||||
|
mode: string | null;
|
||||||
|
bind: string | null;
|
||||||
|
port: number | null;
|
||||||
|
controlUiEnabled: boolean | null;
|
||||||
|
controlUiBasePath: string | null;
|
||||||
|
authMode: string | null;
|
||||||
|
authTokenConfigured: boolean;
|
||||||
|
authPasswordConfigured: boolean;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
remoteTokenConfigured: boolean;
|
||||||
|
remotePasswordConfigured: boolean;
|
||||||
|
tailscaleMode: string | null;
|
||||||
|
};
|
||||||
|
bridge: {
|
||||||
|
enabled: boolean | null;
|
||||||
|
bind: string | null;
|
||||||
|
port: number | null;
|
||||||
|
};
|
||||||
|
discovery: {
|
||||||
|
wideAreaEnabled: boolean | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseIntOrNull(value: unknown): number | null {
|
||||||
|
const s =
|
||||||
|
typeof value === "string"
|
||||||
|
? value.trim()
|
||||||
|
: typeof value === "number" || typeof value === "bigint"
|
||||||
|
? String(value)
|
||||||
|
: "";
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeoutMs(raw: unknown, fallbackMs: number): number {
|
||||||
|
const value =
|
||||||
|
typeof raw === "string"
|
||||||
|
? raw.trim()
|
||||||
|
: typeof raw === "number" || typeof raw === "bigint"
|
||||||
|
? String(raw)
|
||||||
|
: "";
|
||||||
|
if (!value) return fallbackMs;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`invalid --timeout: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWsUrl(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://"))
|
||||||
|
return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargets(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
explicitUrl?: string,
|
||||||
|
): GatewayStatusTarget[] {
|
||||||
|
const targets: GatewayStatusTarget[] = [];
|
||||||
|
const add = (t: GatewayStatusTarget) => {
|
||||||
|
if (!targets.some((x) => x.url === t.url)) targets.push(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const explicit =
|
||||||
|
typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null;
|
||||||
|
if (explicit)
|
||||||
|
add({ id: "explicit", kind: "explicit", url: explicit, active: true });
|
||||||
|
|
||||||
|
const remoteUrl =
|
||||||
|
typeof cfg.gateway?.remote?.url === "string"
|
||||||
|
? normalizeWsUrl(cfg.gateway.remote.url)
|
||||||
|
: null;
|
||||||
|
if (remoteUrl) {
|
||||||
|
add({
|
||||||
|
id: "configRemote",
|
||||||
|
kind: "configRemote",
|
||||||
|
url: remoteUrl,
|
||||||
|
active: cfg.gateway?.mode === "remote",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = resolveGatewayPort(cfg);
|
||||||
|
add({
|
||||||
|
id: "localLoopback",
|
||||||
|
kind: "localLoopback",
|
||||||
|
url: `ws://127.0.0.1:${port}`,
|
||||||
|
active: cfg.gateway?.mode !== "remote",
|
||||||
|
});
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
|
||||||
|
if (kind === "localLoopback") return Math.min(800, overallMs);
|
||||||
|
return Math.min(1500, overallMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthForTarget(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
target: GatewayStatusTarget,
|
||||||
|
overrides: { token?: string; password?: string },
|
||||||
|
): { token?: string; password?: string } {
|
||||||
|
const tokenOverride = overrides.token?.trim()
|
||||||
|
? overrides.token.trim()
|
||||||
|
: undefined;
|
||||||
|
const passwordOverride = overrides.password?.trim()
|
||||||
|
? overrides.password.trim()
|
||||||
|
: undefined;
|
||||||
|
if (tokenOverride || passwordOverride) {
|
||||||
|
return { token: tokenOverride, password: passwordOverride };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.kind === "configRemote") {
|
||||||
|
const token =
|
||||||
|
typeof cfg.gateway?.remote?.token === "string"
|
||||||
|
? cfg.gateway.remote.token.trim()
|
||||||
|
: "";
|
||||||
|
const remotePassword = (
|
||||||
|
cfg.gateway?.remote as { password?: unknown } | undefined
|
||||||
|
)?.password;
|
||||||
|
const password =
|
||||||
|
typeof remotePassword === "string" ? remotePassword.trim() : "";
|
||||||
|
return {
|
||||||
|
token: token.length > 0 ? token : undefined,
|
||||||
|
password: password.length > 0 ? password : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envToken = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || "";
|
||||||
|
const envPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || "";
|
||||||
|
const cfgToken =
|
||||||
|
typeof cfg.gateway?.auth?.token === "string"
|
||||||
|
? cfg.gateway.auth.token.trim()
|
||||||
|
: "";
|
||||||
|
const cfgPassword =
|
||||||
|
typeof cfg.gateway?.auth?.password === "string"
|
||||||
|
? cfg.gateway.auth.password.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: envToken || cfgToken || undefined,
|
||||||
|
password: envPassword || cfgPassword || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGatewaySelfPresence(
|
||||||
|
presence: unknown,
|
||||||
|
): { host?: string; ip?: string; version?: string; platform?: string } | null {
|
||||||
|
if (!Array.isArray(presence)) return null;
|
||||||
|
const entries = presence as Array<Record<string, unknown>>;
|
||||||
|
const self =
|
||||||
|
entries.find((e) => e.mode === "gateway" && e.reason === "self") ??
|
||||||
|
entries.find(
|
||||||
|
(e) =>
|
||||||
|
typeof e.text === "string" && String(e.text).startsWith("Gateway:"),
|
||||||
|
) ??
|
||||||
|
null;
|
||||||
|
if (!self) return null;
|
||||||
|
return {
|
||||||
|
host: typeof self.host === "string" ? self.host : undefined,
|
||||||
|
ip: typeof self.ip === "string" ? self.ip : undefined,
|
||||||
|
version: typeof self.version === "string" ? self.version : undefined,
|
||||||
|
platform: typeof self.platform === "string" ? self.platform : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSummary {
|
||||||
|
const snap = snapshotUnknown as Partial<ConfigFileSnapshot> | null;
|
||||||
|
const path = typeof snap?.path === "string" ? snap.path : null;
|
||||||
|
const exists = Boolean(snap?.exists);
|
||||||
|
const valid = Boolean(snap?.valid);
|
||||||
|
const issuesRaw = Array.isArray(snap?.issues) ? snap.issues : [];
|
||||||
|
const legacyRaw = Array.isArray(snap?.legacyIssues) ? snap.legacyIssues : [];
|
||||||
|
|
||||||
|
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
|
||||||
|
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
|
||||||
|
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
|
||||||
|
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
|
||||||
|
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const remote = (gateway.remote ?? {}) as Record<string, unknown>;
|
||||||
|
const auth = (gateway.auth ?? {}) as Record<string, unknown>;
|
||||||
|
const controlUi = (gateway.controlUi ?? {}) as Record<string, unknown>;
|
||||||
|
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const authMode = typeof auth.mode === "string" ? auth.mode : null;
|
||||||
|
const authTokenConfigured =
|
||||||
|
typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
|
||||||
|
const authPasswordConfigured =
|
||||||
|
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
|
||||||
|
|
||||||
|
const remoteUrl =
|
||||||
|
typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
|
||||||
|
const remoteTokenConfigured =
|
||||||
|
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
|
||||||
|
const remotePasswordConfigured =
|
||||||
|
typeof remote.password === "string"
|
||||||
|
? String(remote.password).trim().length > 0
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const bridgeEnabled =
|
||||||
|
typeof bridge.enabled === "boolean" ? bridge.enabled : null;
|
||||||
|
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
|
||||||
|
const bridgePort = parseIntOrNull(bridge.port);
|
||||||
|
|
||||||
|
const wideAreaEnabled =
|
||||||
|
typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
exists,
|
||||||
|
valid,
|
||||||
|
issues: issuesRaw
|
||||||
|
.filter((i): i is { path: string; message: string } =>
|
||||||
|
Boolean(
|
||||||
|
i && typeof i.path === "string" && typeof i.message === "string",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((i) => ({ path: i.path, message: i.message })),
|
||||||
|
legacyIssues: legacyRaw
|
||||||
|
.filter((i): i is { path: string; message: string } =>
|
||||||
|
Boolean(
|
||||||
|
i && typeof i.path === "string" && typeof i.message === "string",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((i) => ({ path: i.path, message: i.message })),
|
||||||
|
gateway: {
|
||||||
|
mode: typeof gateway.mode === "string" ? gateway.mode : null,
|
||||||
|
bind: typeof gateway.bind === "string" ? gateway.bind : null,
|
||||||
|
port: parseIntOrNull(gateway.port),
|
||||||
|
controlUiEnabled:
|
||||||
|
typeof controlUi.enabled === "boolean" ? controlUi.enabled : null,
|
||||||
|
controlUiBasePath:
|
||||||
|
typeof controlUi.basePath === "string" ? controlUi.basePath : null,
|
||||||
|
authMode,
|
||||||
|
authTokenConfigured,
|
||||||
|
authPasswordConfigured,
|
||||||
|
remoteUrl,
|
||||||
|
remoteTokenConfigured,
|
||||||
|
remotePasswordConfigured,
|
||||||
|
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
|
||||||
|
},
|
||||||
|
bridge: {
|
||||||
|
enabled: bridgeEnabled,
|
||||||
|
bind: bridgeBind,
|
||||||
|
port: bridgePort,
|
||||||
|
},
|
||||||
|
discovery: { wideAreaEnabled },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNetworkHints(cfg: ClawdbotConfig) {
|
||||||
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
|
const port = resolveGatewayPort(cfg);
|
||||||
|
return {
|
||||||
|
localLoopbackUrl: `ws://127.0.0.1:${port}`,
|
||||||
|
localTailnetUrl: tailnetIPv4 ? `ws://${tailnetIPv4}:${port}` : null,
|
||||||
|
tailnetIPv4: tailnetIPv4 ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
|
||||||
|
const kindLabel =
|
||||||
|
target.kind === "localLoopback"
|
||||||
|
? "Local loopback"
|
||||||
|
: target.kind === "configRemote"
|
||||||
|
? target.active
|
||||||
|
? "Remote (configured)"
|
||||||
|
: "Remote (configured, inactive)"
|
||||||
|
: "URL (explicit)";
|
||||||
|
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
|
||||||
|
if (probe.ok) {
|
||||||
|
const latency =
|
||||||
|
typeof probe.connectLatencyMs === "number"
|
||||||
|
? `${probe.connectLatencyMs}ms`
|
||||||
|
: "unknown";
|
||||||
|
return `${colorize(rich, theme.success, "Connect: ok")} (${latency})`;
|
||||||
|
}
|
||||||
|
const detail = probe.error ? ` - ${probe.error}` : "";
|
||||||
|
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gatewayStatusCommand(
|
||||||
|
opts: {
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
password?: string;
|
||||||
|
timeout?: unknown;
|
||||||
|
json?: boolean;
|
||||||
|
},
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const rich = isRich() && opts.json !== true;
|
||||||
|
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
|
||||||
|
|
||||||
|
const targets = resolveTargets(cfg, opts.url);
|
||||||
|
const network = buildNetworkHints(cfg);
|
||||||
|
|
||||||
|
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
|
||||||
|
const discoveryPromise = discoverGatewayBeacons({
|
||||||
|
timeoutMs: discoveryTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const probePromises = targets.map(async (target) => {
|
||||||
|
const auth = resolveAuthForTarget(cfg, target, {
|
||||||
|
token: typeof opts.token === "string" ? opts.token : undefined,
|
||||||
|
password: typeof opts.password === "string" ? opts.password : undefined,
|
||||||
|
});
|
||||||
|
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
|
||||||
|
const probe = await probeGateway({ url: target.url, auth, timeoutMs });
|
||||||
|
const configSummary = probe.configSnapshot
|
||||||
|
? extractConfigSummary(probe.configSnapshot)
|
||||||
|
: null;
|
||||||
|
const self = pickGatewaySelfPresence(probe.presence);
|
||||||
|
return { target, probe, configSummary, self };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { discovery, probed } = await withProgress(
|
||||||
|
{
|
||||||
|
label: "Inspecting gateways…",
|
||||||
|
indeterminate: true,
|
||||||
|
enabled: opts.json !== true,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const [discoveryRes, probesRes] = await Promise.allSettled([
|
||||||
|
discoveryPromise,
|
||||||
|
Promise.all(probePromises),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
discovery:
|
||||||
|
discoveryRes.status === "fulfilled" ? discoveryRes.value : [],
|
||||||
|
probed: probesRes.status === "fulfilled" ? probesRes.value : [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const reachable = probed.filter((p) => p.probe.ok);
|
||||||
|
const ok = reachable.length > 0;
|
||||||
|
const multipleGateways = reachable.length > 1;
|
||||||
|
const primary =
|
||||||
|
reachable.find((p) => p.target.kind === "explicit") ??
|
||||||
|
reachable.find((p) => p.target.kind === "configRemote") ??
|
||||||
|
reachable.find((p) => p.target.kind === "localLoopback") ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const warnings: Array<{
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
targetIds?: string[];
|
||||||
|
}> = [];
|
||||||
|
if (multipleGateways) {
|
||||||
|
warnings.push({
|
||||||
|
code: "multiple_gateways",
|
||||||
|
message:
|
||||||
|
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.",
|
||||||
|
targetIds: reachable.map((p) => p.target.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
runtime.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok,
|
||||||
|
ts: Date.now(),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
timeoutMs: overallTimeoutMs,
|
||||||
|
primaryTargetId: primary?.target.id ?? null,
|
||||||
|
warnings,
|
||||||
|
network,
|
||||||
|
discovery: {
|
||||||
|
timeoutMs: discoveryTimeoutMs,
|
||||||
|
count: discovery.length,
|
||||||
|
beacons: discovery.map((b) => ({
|
||||||
|
instanceName: b.instanceName,
|
||||||
|
displayName: b.displayName ?? null,
|
||||||
|
domain: b.domain ?? null,
|
||||||
|
host: b.host ?? null,
|
||||||
|
lanHost: b.lanHost ?? null,
|
||||||
|
tailnetDns: b.tailnetDns ?? null,
|
||||||
|
bridgePort: b.bridgePort ?? null,
|
||||||
|
gatewayPort: b.gatewayPort ?? null,
|
||||||
|
sshPort: b.sshPort ?? null,
|
||||||
|
wsUrl: (() => {
|
||||||
|
const host = b.tailnetDns || b.lanHost || b.host;
|
||||||
|
const port = b.gatewayPort ?? 18789;
|
||||||
|
return host ? `ws://${host}:${port}` : null;
|
||||||
|
})(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
targets: probed.map((p) => ({
|
||||||
|
id: p.target.id,
|
||||||
|
kind: p.target.kind,
|
||||||
|
url: p.target.url,
|
||||||
|
active: p.target.active,
|
||||||
|
connect: {
|
||||||
|
ok: p.probe.ok,
|
||||||
|
latencyMs: p.probe.connectLatencyMs,
|
||||||
|
error: p.probe.error,
|
||||||
|
close: p.probe.close,
|
||||||
|
},
|
||||||
|
self: p.self,
|
||||||
|
config: p.configSummary,
|
||||||
|
health: p.probe.health,
|
||||||
|
summary: p.probe.status,
|
||||||
|
presence: p.probe.presence,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!ok) runtime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.log(colorize(rich, theme.heading, "Gateway Status"));
|
||||||
|
runtime.log(
|
||||||
|
ok
|
||||||
|
? `${colorize(rich, theme.success, "Reachable")}: yes`
|
||||||
|
: `${colorize(rich, theme.error, "Reachable")}: no`,
|
||||||
|
);
|
||||||
|
runtime.log(
|
||||||
|
colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
runtime.log("");
|
||||||
|
runtime.log(colorize(rich, theme.warn, "Warning:"));
|
||||||
|
for (const w of warnings) runtime.log(`- ${w.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.log("");
|
||||||
|
runtime.log(colorize(rich, theme.heading, "Discovery (this machine)"));
|
||||||
|
runtime.log(
|
||||||
|
discovery.length > 0
|
||||||
|
? `Found ${discovery.length} gateway(s) via Bonjour (local. + clawdbot.internal.)`
|
||||||
|
: "Found 0 gateways via Bonjour (local. + clawdbot.internal.)",
|
||||||
|
);
|
||||||
|
if (discovery.length === 0) {
|
||||||
|
runtime.log(
|
||||||
|
colorize(
|
||||||
|
rich,
|
||||||
|
theme.muted,
|
||||||
|
"Tip: if the gateway is remote, mDNS won’t cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.log("");
|
||||||
|
runtime.log(colorize(rich, theme.heading, "Targets"));
|
||||||
|
for (const p of probed) {
|
||||||
|
runtime.log(renderTargetHeader(p.target, rich));
|
||||||
|
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
|
||||||
|
if (p.probe.ok && p.self) {
|
||||||
|
const host = p.self.host ?? "unknown";
|
||||||
|
const ip = p.self.ip ? ` (${p.self.ip})` : "";
|
||||||
|
const platform = p.self.platform ? ` · ${p.self.platform}` : "";
|
||||||
|
const version = p.self.version ? ` · app ${p.self.version}` : "";
|
||||||
|
runtime.log(
|
||||||
|
` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.configSummary) {
|
||||||
|
const c = p.configSummary;
|
||||||
|
const bridge =
|
||||||
|
c.bridge.enabled === false
|
||||||
|
? "disabled"
|
||||||
|
: c.bridge.enabled === true
|
||||||
|
? "enabled"
|
||||||
|
: "unknown";
|
||||||
|
const wideArea =
|
||||||
|
c.discovery.wideAreaEnabled === true
|
||||||
|
? "enabled"
|
||||||
|
: c.discovery.wideAreaEnabled === false
|
||||||
|
? "disabled"
|
||||||
|
: "unknown";
|
||||||
|
runtime.log(
|
||||||
|
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
|
||||||
|
);
|
||||||
|
runtime.log(
|
||||||
|
` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
runtime.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) runtime.exit(1);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
cancel,
|
cancel,
|
||||||
isCancel,
|
|
||||||
multiselect as clackMultiselect,
|
multiselect as clackMultiselect,
|
||||||
|
isCancel,
|
||||||
} from "@clack/prompts";
|
} from "@clack/prompts";
|
||||||
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
|
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -288,7 +288,9 @@ export async function modelsScanCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isCancel(selection)) {
|
if (isCancel(selection)) {
|
||||||
cancel(stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.");
|
cancel(
|
||||||
|
stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.",
|
||||||
|
);
|
||||||
runtime.exit(0);
|
runtime.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CODEX_CLI_PROFILE_ID,
|
CODEX_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
} from "../agents/auth-profiles.js";
|
} from "../agents/auth-profiles.js";
|
||||||
|
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||||
import {
|
import {
|
||||||
type ClawdbotConfig,
|
type ClawdbotConfig,
|
||||||
CONFIG_PATH_CLAWDBOT,
|
CONFIG_PATH_CLAWDBOT,
|
||||||
@@ -16,6 +17,7 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
|||||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
|
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
@@ -135,6 +137,19 @@ export async function runNonInteractiveOnboarding(
|
|||||||
mode: "api_key",
|
mode: "api_key",
|
||||||
});
|
});
|
||||||
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
|
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
|
||||||
|
} else if (authChoice === "openai-api-key") {
|
||||||
|
const key = opts.openaiApiKey?.trim() || resolveEnvApiKey("openai")?.apiKey;
|
||||||
|
if (!key) {
|
||||||
|
runtime.error("Missing --openai-api-key (or OPENAI_API_KEY in env).");
|
||||||
|
runtime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = upsertSharedEnvVar({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: key,
|
||||||
|
});
|
||||||
|
process.env.OPENAI_API_KEY = key;
|
||||||
|
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
|
||||||
} else if (authChoice === "claude-cli") {
|
} else if (authChoice === "claude-cli") {
|
||||||
const store = ensureAuthProfileStore(undefined, {
|
const store = ensureAuthProfileStore(undefined, {
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AuthChoice =
|
|||||||
| "claude-cli"
|
| "claude-cli"
|
||||||
| "token"
|
| "token"
|
||||||
| "openai-codex"
|
| "openai-codex"
|
||||||
|
| "openai-api-key"
|
||||||
| "codex-cli"
|
| "codex-cli"
|
||||||
| "antigravity"
|
| "antigravity"
|
||||||
| "apiKey"
|
| "apiKey"
|
||||||
@@ -26,6 +27,7 @@ export type OnboardOptions = {
|
|||||||
nonInteractive?: boolean;
|
nonInteractive?: boolean;
|
||||||
authChoice?: AuthChoice;
|
authChoice?: AuthChoice;
|
||||||
anthropicApiKey?: string;
|
anthropicApiKey?: string;
|
||||||
|
openaiApiKey?: string;
|
||||||
geminiApiKey?: string;
|
geminiApiKey?: string;
|
||||||
gatewayPort?: number;
|
gatewayPort?: number;
|
||||||
gatewayBind?: GatewayBind;
|
gatewayBind?: GatewayBind;
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import process from "node:process";
|
|||||||
|
|
||||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
|
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
|
||||||
|
|
||||||
|
if (process.argv.includes("--no-color")) {
|
||||||
|
process.env.NO_COLOR = "1";
|
||||||
|
process.env.FORCE_COLOR = "0";
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseCliProfileArgs(process.argv);
|
const parsed = parseCliProfileArgs(process.argv);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
// Keep it simple; Commander will handle rich help/errors after we strip flags.
|
// Keep it simple; Commander will handle rich help/errors after we strip flags.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type GatewayClientOptions = {
|
|||||||
maxProtocol?: number;
|
maxProtocol?: number;
|
||||||
onEvent?: (evt: EventFrame) => void;
|
onEvent?: (evt: EventFrame) => void;
|
||||||
onHelloOk?: (hello: HelloOk) => void;
|
onHelloOk?: (hello: HelloOk) => void;
|
||||||
|
onConnectError?: (err: Error) => void;
|
||||||
onClose?: (code: number, reason: string) => void;
|
onClose?: (code: number, reason: string) => void;
|
||||||
onGap?: (info: { expected: number; received: number }) => void;
|
onGap?: (info: { expected: number; received: number }) => void;
|
||||||
};
|
};
|
||||||
@@ -130,6 +131,9 @@ export class GatewayClient {
|
|||||||
this.opts.onHelloOk?.(helloOk);
|
this.opts.onHelloOk?.(helloOk);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
this.opts.onConnectError?.(
|
||||||
|
err instanceof Error ? err : new Error(String(err)),
|
||||||
|
);
|
||||||
const msg = `gateway connect failed: ${String(err)}`;
|
const msg = `gateway connect failed: ${String(err)}`;
|
||||||
if (this.opts.mode === "probe") logDebug(msg);
|
if (this.opts.mode === "probe") logDebug(msg);
|
||||||
else logError(msg);
|
else logError(msg);
|
||||||
|
|||||||
123
src/gateway/probe.ts
Normal file
123
src/gateway/probe.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type { SystemPresence } from "../infra/system-presence.js";
|
||||||
|
import { GatewayClient } from "./client.js";
|
||||||
|
|
||||||
|
export type GatewayProbeAuth = {
|
||||||
|
token?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayProbeClose = {
|
||||||
|
code: number;
|
||||||
|
reason: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayProbeResult = {
|
||||||
|
ok: boolean;
|
||||||
|
url: string;
|
||||||
|
connectLatencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
close: GatewayProbeClose | null;
|
||||||
|
health: unknown;
|
||||||
|
status: unknown;
|
||||||
|
presence: SystemPresence[] | null;
|
||||||
|
configSnapshot: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatError(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeGateway(opts: {
|
||||||
|
url: string;
|
||||||
|
auth?: GatewayProbeAuth;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<GatewayProbeResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const instanceId = randomUUID();
|
||||||
|
let connectLatencyMs: number | null = null;
|
||||||
|
let connectError: string | null = null;
|
||||||
|
let close: GatewayProbeClose | null = null;
|
||||||
|
|
||||||
|
return await new Promise<GatewayProbeResult>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = (result: Omit<GatewayProbeResult, "url">) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.stop();
|
||||||
|
resolve({ url: opts.url, ...result });
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new GatewayClient({
|
||||||
|
url: opts.url,
|
||||||
|
token: opts.auth?.token,
|
||||||
|
password: opts.auth?.password,
|
||||||
|
clientName: "cli",
|
||||||
|
clientVersion: "dev",
|
||||||
|
mode: "probe",
|
||||||
|
instanceId,
|
||||||
|
onConnectError: (err) => {
|
||||||
|
connectError = formatError(err);
|
||||||
|
},
|
||||||
|
onClose: (code, reason) => {
|
||||||
|
close = { code, reason };
|
||||||
|
},
|
||||||
|
onHelloOk: async () => {
|
||||||
|
connectLatencyMs = Date.now() - startedAt;
|
||||||
|
try {
|
||||||
|
const [health, status, presence, configSnapshot] = await Promise.all([
|
||||||
|
client.request("health"),
|
||||||
|
client.request("status"),
|
||||||
|
client.request("system-presence"),
|
||||||
|
client.request("config.get", {}),
|
||||||
|
]);
|
||||||
|
settle({
|
||||||
|
ok: true,
|
||||||
|
connectLatencyMs,
|
||||||
|
error: null,
|
||||||
|
close,
|
||||||
|
health,
|
||||||
|
status,
|
||||||
|
presence: Array.isArray(presence)
|
||||||
|
? (presence as SystemPresence[])
|
||||||
|
: null,
|
||||||
|
configSnapshot,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
settle({
|
||||||
|
ok: false,
|
||||||
|
connectLatencyMs,
|
||||||
|
error: formatError(err),
|
||||||
|
close,
|
||||||
|
health: null,
|
||||||
|
status: null,
|
||||||
|
presence: null,
|
||||||
|
configSnapshot: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
settle({
|
||||||
|
ok: false,
|
||||||
|
connectLatencyMs,
|
||||||
|
error: connectError ? `connect failed: ${connectError}` : "timeout",
|
||||||
|
close,
|
||||||
|
health: null,
|
||||||
|
status: null,
|
||||||
|
presence: null,
|
||||||
|
configSnapshot: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Math.max(250, opts.timeoutMs),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
55
src/infra/env-file.ts
Normal file
55
src/infra/env-file.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveConfigDir } from "../utils.js";
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertSharedEnvVar(params: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): { path: string; updated: boolean; created: boolean } {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const dir = resolveConfigDir(env);
|
||||||
|
const filepath = path.join(dir, ".env");
|
||||||
|
const key = params.key.trim();
|
||||||
|
const value = params.value;
|
||||||
|
|
||||||
|
let raw = "";
|
||||||
|
if (fs.existsSync(filepath)) {
|
||||||
|
raw = fs.readFileSync(filepath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = raw.length ? raw.split(/\r?\n/) : [];
|
||||||
|
const matcher = new RegExp(`^(\\s*(?:export\\s+)?)${escapeRegExp(key)}\\s*=`);
|
||||||
|
let updated = false;
|
||||||
|
let replaced = false;
|
||||||
|
|
||||||
|
const nextLines = lines.map((line) => {
|
||||||
|
const match = line.match(matcher);
|
||||||
|
if (!match) return line;
|
||||||
|
replaced = true;
|
||||||
|
const prefix = match[1] ?? "";
|
||||||
|
const next = `${prefix}${key}=${value}`;
|
||||||
|
if (next !== line) updated = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!replaced) {
|
||||||
|
nextLines.push(`${key}=${value}`);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${nextLines.join("\n")}\n`;
|
||||||
|
fs.writeFileSync(filepath, output, "utf8");
|
||||||
|
fs.chmodSync(filepath, 0o600);
|
||||||
|
|
||||||
|
return { path: filepath, updated, created: !raw };
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import chalk from "chalk";
|
import chalk, { Chalk } from "chalk";
|
||||||
|
|
||||||
import { LOBSTER_PALETTE } from "./palette.js";
|
import { LOBSTER_PALETTE } from "./palette.js";
|
||||||
|
|
||||||
const hex = (value: string) => chalk.hex(value);
|
const baseChalk = process.env.NO_COLOR ? new Chalk({ level: 0 }) : chalk;
|
||||||
|
|
||||||
|
const hex = (value: string) => baseChalk.hex(value);
|
||||||
|
|
||||||
export const theme = {
|
export const theme = {
|
||||||
accent: hex(LOBSTER_PALETTE.accent),
|
accent: hex(LOBSTER_PALETTE.accent),
|
||||||
@@ -13,12 +15,13 @@ export const theme = {
|
|||||||
warn: hex(LOBSTER_PALETTE.warn),
|
warn: hex(LOBSTER_PALETTE.warn),
|
||||||
error: hex(LOBSTER_PALETTE.error),
|
error: hex(LOBSTER_PALETTE.error),
|
||||||
muted: hex(LOBSTER_PALETTE.muted),
|
muted: hex(LOBSTER_PALETTE.muted),
|
||||||
heading: chalk.bold.hex(LOBSTER_PALETTE.accent),
|
heading: baseChalk.bold.hex(LOBSTER_PALETTE.accent),
|
||||||
command: hex(LOBSTER_PALETTE.accentBright),
|
command: hex(LOBSTER_PALETTE.accentBright),
|
||||||
option: hex(LOBSTER_PALETTE.warn),
|
option: hex(LOBSTER_PALETTE.warn),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0);
|
export const isRich = () =>
|
||||||
|
Boolean(process.stdout.isTTY && baseChalk.level > 0);
|
||||||
|
|
||||||
export const colorize = (
|
export const colorize = (
|
||||||
rich: boolean,
|
rich: boolean,
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from "@clack/prompts";
|
} from "@clack/prompts";
|
||||||
import { createCliProgress } from "../cli/progress.js";
|
import { createCliProgress } from "../cli/progress.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
|
||||||
import {
|
import {
|
||||||
stylePromptHint,
|
stylePromptHint,
|
||||||
stylePromptMessage,
|
stylePromptMessage,
|
||||||
stylePromptTitle,
|
stylePromptTitle,
|
||||||
} from "../terminal/prompt-style.js";
|
} from "../terminal/prompt-style.js";
|
||||||
|
import { theme } from "../terminal/theme.js";
|
||||||
import type { WizardProgress, WizardPrompter } from "./prompts.js";
|
import type { WizardProgress, WizardPrompter } from "./prompts.js";
|
||||||
import { WizardCancelledError } from "./prompts.js";
|
import { WizardCancelledError } from "./prompts.js";
|
||||||
|
|
||||||
|
|||||||
@@ -548,65 +548,66 @@ export async function runOnboardingWizard(
|
|||||||
"Optional apps",
|
"Optional apps",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const links = resolveControlUiLinks({
|
||||||
|
bind,
|
||||||
|
port,
|
||||||
|
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||||
|
});
|
||||||
|
const tokenParam =
|
||||||
|
authMode === "token" && gatewayToken
|
||||||
|
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||||
|
: "";
|
||||||
|
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||||
|
const gatewayProbe = await probeGatewayReachable({
|
||||||
|
url: links.wsUrl,
|
||||||
|
token: authMode === "token" ? gatewayToken : undefined,
|
||||||
|
password: authMode === "password" ? baseConfig.gateway?.auth?.password : "",
|
||||||
|
});
|
||||||
|
const gatewayStatusLine = gatewayProbe.ok
|
||||||
|
? "Gateway: reachable"
|
||||||
|
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||||
|
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
(() => {
|
[
|
||||||
const links = resolveControlUiLinks({
|
`Web UI: ${links.httpUrl}`,
|
||||||
bind,
|
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||||
port,
|
`Gateway WS: ${links.wsUrl}`,
|
||||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
gatewayStatusLine,
|
||||||
});
|
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||||
const tokenParam =
|
]
|
||||||
authMode === "token" && gatewayToken
|
.filter(Boolean)
|
||||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
.join("\n"),
|
||||||
: "";
|
|
||||||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
|
||||||
return [
|
|
||||||
`Web UI: ${links.httpUrl}`,
|
|
||||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
|
||||||
`Gateway WS: ${links.wsUrl}`,
|
|
||||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
})(),
|
|
||||||
"Control UI",
|
"Control UI",
|
||||||
);
|
);
|
||||||
|
|
||||||
const browserSupport = await detectBrowserOpenSupport();
|
const browserSupport = await detectBrowserOpenSupport();
|
||||||
if (!browserSupport.ok) {
|
if (gatewayProbe.ok) {
|
||||||
await prompter.note(
|
if (!browserSupport.ok) {
|
||||||
formatControlUiSshHint({
|
await prompter.note(
|
||||||
port,
|
formatControlUiSshHint({
|
||||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
port,
|
||||||
token: authMode === "token" ? gatewayToken : undefined,
|
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||||
}),
|
token: authMode === "token" ? gatewayToken : undefined,
|
||||||
"Open Control UI",
|
}),
|
||||||
);
|
"Open Control UI",
|
||||||
} else {
|
);
|
||||||
const wantsOpen = await prompter.confirm({
|
} else {
|
||||||
message: "Open Control UI now?",
|
const wantsOpen = await prompter.confirm({
|
||||||
initialValue: true,
|
message: "Open Control UI now?",
|
||||||
});
|
initialValue: true,
|
||||||
if (wantsOpen) {
|
|
||||||
const links = resolveControlUiLinks({
|
|
||||||
bind,
|
|
||||||
port,
|
|
||||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
|
||||||
});
|
});
|
||||||
const tokenParam =
|
if (wantsOpen) {
|
||||||
authMode === "token" && gatewayToken
|
const opened = await openUrl(`${links.httpUrl}${tokenParam}`);
|
||||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
if (!opened) {
|
||||||
: "";
|
await prompter.note(
|
||||||
const opened = await openUrl(`${links.httpUrl}${tokenParam}`);
|
formatControlUiSshHint({
|
||||||
if (!opened) {
|
port,
|
||||||
await prompter.note(
|
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||||
formatControlUiSshHint({
|
token: authMode === "token" ? gatewayToken : undefined,
|
||||||
port,
|
}),
|
||||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
"Open Control UI",
|
||||||
token: authMode === "token" ? gatewayToken : undefined,
|
);
|
||||||
}),
|
}
|
||||||
"Open Control UI",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ export async function loadSessions(state: SessionsState) {
|
|||||||
export async function patchSession(
|
export async function patchSession(
|
||||||
state: SessionsState,
|
state: SessionsState,
|
||||||
key: string,
|
key: string,
|
||||||
patch: { thinkingLevel?: string | null; verboseLevel?: string | null },
|
patch: {
|
||||||
|
thinkingLevel?: string | null;
|
||||||
|
verboseLevel?: string | null;
|
||||||
|
reasoningLevel?: string | null;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
const params: Record<string, unknown> = { key };
|
const params: Record<string, unknown> = { key };
|
||||||
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
|
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
|
||||||
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
|
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
|
||||||
|
if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel;
|
||||||
try {
|
try {
|
||||||
await state.client.request("sessions.patch", params);
|
await state.client.request("sessions.patch", params);
|
||||||
await loadSessions(state);
|
await loadSessions(state);
|
||||||
@@ -55,4 +60,3 @@ export async function patchSession(
|
|||||||
state.sessionsError = String(err);
|
state.sessionsError = String(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export function renderChat(props: ChatProps) {
|
|||||||
const canCompose = props.connected;
|
const canCompose = props.connected;
|
||||||
const isBusy = props.sending || Boolean(props.stream);
|
const isBusy = props.sending || Boolean(props.stream);
|
||||||
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
|
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
|
||||||
|
const activeSession = props.sessions?.sessions?.find(
|
||||||
|
(row) => row.key === props.sessionKey,
|
||||||
|
);
|
||||||
|
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
|
||||||
|
const showReasoning = reasoningLevel !== "off";
|
||||||
const composePlaceholder = props.connected
|
const composePlaceholder = props.connected
|
||||||
? "Message (↩ to send, Shift+↩ for line breaks)"
|
? "Message (↩ to send, Shift+↩ for line breaks)"
|
||||||
: "Connect to the gateway to start chatting…";
|
: "Connect to the gateway to start chatting…";
|
||||||
@@ -72,6 +77,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="chat-header__right">
|
<div class="chat-header__right">
|
||||||
<div class="muted">Thinking: ${props.thinkingLevel ?? "inherit"}</div>
|
<div class="muted">Thinking: ${props.thinkingLevel ?? "inherit"}</div>
|
||||||
|
<div class="muted">Reasoning: ${reasoningLevel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,7 +113,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
{ streaming: true }
|
{ streaming: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return renderMessage(item.message, props);
|
return renderMessage(item.message, props, { showReasoning });
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +332,7 @@ function renderReadingIndicator() {
|
|||||||
function renderMessage(
|
function renderMessage(
|
||||||
message: unknown,
|
message: unknown,
|
||||||
props?: Pick<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">,
|
props?: Pick<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">,
|
||||||
opts?: { streaming?: boolean }
|
opts?: { streaming?: boolean; showReasoning?: boolean }
|
||||||
) {
|
) {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||||
@@ -334,6 +340,10 @@ function renderMessage(
|
|||||||
const hasToolCards = toolCards.length > 0;
|
const hasToolCards = toolCards.length > 0;
|
||||||
const isToolResult = isToolResultMessage(message);
|
const isToolResult = isToolResultMessage(message);
|
||||||
const extractedText = extractText(message);
|
const extractedText = extractText(message);
|
||||||
|
const extractedThinking =
|
||||||
|
opts?.showReasoning && role === "assistant"
|
||||||
|
? extractThinking(message)
|
||||||
|
: null;
|
||||||
const contentText = typeof m.content === "string" ? m.content : null;
|
const contentText = typeof m.content === "string" ? m.content : null;
|
||||||
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
||||||
|
|
||||||
@@ -345,10 +355,15 @@ function renderMessage(
|
|||||||
: !isToolResult && fallback
|
: !isToolResult && fallback
|
||||||
? { kind: "json" as const, value: fallback }
|
? { kind: "json" as const, value: fallback }
|
||||||
: null;
|
: null;
|
||||||
const markdown =
|
const markdownBase =
|
||||||
display?.kind === "json"
|
display?.kind === "json"
|
||||||
? ["```json", display.value, "```"].join("\n")
|
? ["```json", display.value, "```"].join("\n")
|
||||||
: (display?.value ?? null);
|
: (display?.value ?? null);
|
||||||
|
const markdown = extractedThinking
|
||||||
|
? [formatReasoningMarkdown(extractedThinking), markdownBase]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n")
|
||||||
|
: markdownBase;
|
||||||
|
|
||||||
const timestamp =
|
const timestamp =
|
||||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||||
@@ -413,6 +428,60 @@ function extractText(message: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractThinking(message: unknown): string | null {
|
||||||
|
const m = message as Record<string, unknown>;
|
||||||
|
const content = m.content;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const p of content) {
|
||||||
|
const item = p as Record<string, unknown>;
|
||||||
|
if (item.type === "thinking" && typeof item.thinking === "string") {
|
||||||
|
const cleaned = item.thinking.trim();
|
||||||
|
if (cleaned) parts.push(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length > 0) return parts.join("\n");
|
||||||
|
|
||||||
|
// Back-compat: older logs may still have <think> tags inside text blocks.
|
||||||
|
const rawText = extractRawText(message);
|
||||||
|
if (!rawText) return null;
|
||||||
|
const matches = [...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi)];
|
||||||
|
const extracted = matches
|
||||||
|
.map((m) => (m[1] ?? "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return extracted.length > 0 ? extracted.join("\n") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRawText(message: unknown): string | null {
|
||||||
|
const m = message as Record<string, unknown>;
|
||||||
|
const content = m.content;
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const parts = content
|
||||||
|
.map((p) => {
|
||||||
|
const item = p as Record<string, unknown>;
|
||||||
|
if (item.type === "text" && typeof item.text === "string") return item.text;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((v): v is string => typeof v === "string");
|
||||||
|
if (parts.length > 0) return parts.join("\n");
|
||||||
|
}
|
||||||
|
if (typeof m.text === "string") return m.text;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReasoningMarkdown(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const lines = trimmed
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => `_${line}_`);
|
||||||
|
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
|
||||||
|
}
|
||||||
|
|
||||||
type ToolCard = {
|
type ToolCard = {
|
||||||
kind: "call" | "result";
|
kind: "call" | "result";
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ export type SessionsProps = {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onPatch: (
|
onPatch: (
|
||||||
key: string,
|
key: string,
|
||||||
patch: { thinkingLevel?: string | null; verboseLevel?: string | null },
|
patch: {
|
||||||
|
thinkingLevel?: string | null;
|
||||||
|
verboseLevel?: string | null;
|
||||||
|
reasoningLevel?: string | null;
|
||||||
|
},
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const;
|
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const;
|
||||||
const VERBOSE_LEVELS = ["", "off", "on"] as const;
|
const VERBOSE_LEVELS = ["", "off", "on"] as const;
|
||||||
|
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
||||||
|
|
||||||
export function renderSessions(props: SessionsProps) {
|
export function renderSessions(props: SessionsProps) {
|
||||||
const rows = props.result?.sessions ?? [];
|
const rows = props.result?.sessions ?? [];
|
||||||
@@ -117,6 +122,7 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
<div>Tokens</div>
|
<div>Tokens</div>
|
||||||
<div>Thinking</div>
|
<div>Thinking</div>
|
||||||
<div>Verbose</div>
|
<div>Verbose</div>
|
||||||
|
<div>Reasoning</div>
|
||||||
</div>
|
</div>
|
||||||
${rows.length === 0
|
${rows.length === 0
|
||||||
? html`<div class="muted">No sessions found.</div>`
|
? html`<div class="muted">No sessions found.</div>`
|
||||||
@@ -130,6 +136,7 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr
|
|||||||
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
|
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
|
||||||
const thinking = row.thinkingLevel ?? "";
|
const thinking = row.thinkingLevel ?? "";
|
||||||
const verbose = row.verboseLevel ?? "";
|
const verbose = row.verboseLevel ?? "";
|
||||||
|
const reasoning = row.reasoningLevel ?? "";
|
||||||
const displayName = row.displayName ?? row.key;
|
const displayName = row.displayName ?? row.key;
|
||||||
const canLink = row.kind !== "global";
|
const canLink = row.kind !== "global";
|
||||||
const chatUrl = canLink
|
const chatUrl = canLink
|
||||||
@@ -170,6 +177,19 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
.value=${reasoning}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
|
onPatch(row.key, { reasoningLevel: value || null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${REASONING_LEVELS.map((level) =>
|
||||||
|
html`<option value=${level}>${level || "inherit"}</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user