Compare commits

..
Author SHA1 Message Date
Peter Steinberger 0fbf728298 fix: sync macOS voice wake trigger words on blur/submit (#1506) (thanks @shiv19) 2026-01-23 19:54:01 +00:00
Shiva Prasad 16c4a3aea6 macOS: fix trigger word input disappearing when typing and on add
Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time.

Solution: Introduced local `@State` buffer `triggerEntries` with stable UUID identifiers for each trigger word entry. User edits now only affect the local state buffer and are synced back to `AppState` on explicit actions (submit, remove, disappear). This prevents premature sanitization during editing.

The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`.
2026-01-23 19:54:01 +00:00
Peter Steinberger 242add587f fix: quiet auth probe diagnostics 2026-01-23 19:53:01 +00:00
Peter Steinberger 6fba598eaf fix: handle gateway slash command replies in TUI 2026-01-23 19:48:22 +00:00
Peter Steinberger 75a54f0259 docs: note models usage suppression 2026-01-23 19:43:26 +00:00
Peter Steinberger c63144ab14 fix: hide usage errors in status 2026-01-23 19:43:26 +00:00
Peter Steinberger f07c39b265 docs: handle lint/format churn 2026-01-23 19:37:33 +00:00
Peter Steinberger 40181afded feat: add models status auth probes 2026-01-23 19:28:55 +00:00
Peter Steinberger 2f1b9efe9a style: wrap service path helpers 2026-01-23 19:17:57 +00:00
Peter Steinberger ff30cef8a4 fix: expand linux service PATH handling 2026-01-23 19:16:41 +00:00
Robby 3d958d5466 fix(linux): add user bin directories to systemd service PATH for skill installation (#1512)
* fix(linux): add user bin directories to systemd service PATH

Fixes #1503

On Linux, the systemd service PATH was hardcoded to only include system
directories (/usr/local/bin, /usr/bin, /bin), causing binaries installed
via npm global with custom prefix or node version managers to not be found.

This adds common Linux user bin directories to the PATH:
- ~/.local/bin (XDG standard, pip, etc.)
- ~/.npm-global/bin (npm custom prefix)
- ~/bin (user's personal bin)
- Node version manager paths (nvm, fnm, volta, asdf)
- ~/.local/share/pnpm (pnpm global)
- ~/.bun/bin (Bun)

User directories are added before system directories so user-installed
binaries take precedence.

🤖 AI-assisted (Claude Opus 4.5 via Clawdbot)
📋 Testing: Existing unit tests pass (7/7)

* test: add comprehensive tests for Linux user bin directory resolution

- Add dedicated tests for resolveLinuxUserBinDirs() function
- Test path ordering (extraDirs > user dirs > system dirs)
- Test buildMinimalServicePath() with HOME set/unset
- Test platform-specific behavior (Linux vs macOS vs Windows)

Test count: 7 → 20 (+13 tests)

* test: add comprehensive tests for Linux user bin directory handling

- Test Linux user directories included when HOME is set
- Test Linux user directories excluded when HOME is missing
- Test path ordering (extraDirs > user dirs > system dirs)
- Test platform-specific behavior (Linux vs macOS vs Windows)
- Test buildMinimalServicePath() with HOME in env

Covers getMinimalServicePathParts() and buildMinimalServicePath()
for all Linux user bin directory edge cases.

Test count: 7 → 16 (+9 tests)
2026-01-23 19:06:14 +00:00
Peter Steinberger cad7ed1cb8 fix(exec-approvals): stabilize allowlist ids (#1521) 2026-01-23 19:00:45 +00:00
Peter Steinberger 8195497cec fix: surface gateway slash commands in TUI 2026-01-23 18:58:41 +00:00
47 changed files with 1215 additions and 190 deletions

No files matched your search

+4
View File
@@ -128,6 +128,10 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
+8 -1
View File
@@ -6,13 +6,20 @@ Docs: https://docs.clawd.bot
### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes
- macOS: sync voice wake trigger words on submit/blur in settings. (#1506) Thanks @shiv19.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Gateway/Linux: include user + env-configured bin dirs in systemd PATH and align service audit checks. (#1512) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
## 2026.1.22
@@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
struct ExecAllowlistEntry: Codable, Hashable {
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
var pattern: String
var lastUsedAt: Double?
var lastUsedCommand: String?
var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
}
struct ExecApprovalsDefaults: Codable {
@@ -295,6 +336,7 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
@@ -379,6 +421,7 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
@@ -398,6 +441,7 @@ enum ExecApprovalsStore {
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ForEach(self.model.entries, id: \.id) { entry in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(id: entry.id) })
}
}
}
@@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(at index: Int) {
func removeEntry(id: UUID) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []
@@ -21,6 +21,8 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
@FocusState private var focusedTriggerEntryID: UUID?
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -31,9 +33,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct IndexedWord: Identifiable {
let id: Int
let value: String
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -105,6 +107,7 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -122,8 +125,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -136,11 +141,21 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
.onChange(of: self.state.swabbleTriggerWords) { _, _ in
guard !self.isPreview else { return }
guard self.focusedTriggerEntryID == nil else { return }
self.loadTriggerEntries()
}
}
private var indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
private func loadTriggerEntries() {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
}
private var triggerTable: some View {
@@ -154,29 +169,47 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
Table(self.indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: self.binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.focused(self.$focusedTriggerEntryID, equals: entry.id)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(minHeight: 180)
.onChange(of: self.focusedTriggerEntryID) { oldValue, newValue in
guard oldValue != nil, oldValue != newValue else { return }
self.syncTriggerEntriesToState()
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -211,24 +244,12 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.state.swabbleTriggerWords.append("")
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
}
private func removeWord(at index: Int) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
}
private func toggleTest() {
@@ -638,13 +659,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true)
var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -654,8 +676,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
}
}
#endif
+7
View File
@@ -700,8 +700,15 @@ Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>`
Set `agents.defaults.model.primary`.
+14
View File
@@ -25,12 +25,26 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks
```bash
+1
View File
@@ -324,6 +324,7 @@ brew install <formula>
```
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### Can I switch between npm and git installs later?
+2
View File
@@ -54,6 +54,7 @@ Example schema:
"autoAllowSkills": true,
"allowlist": [
{
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
@@ -96,6 +97,7 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp
- **last used command**
- **last resolved path**
+2
View File
@@ -88,6 +88,8 @@ Session lifecycle:
- `/settings`
- `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
+6 -2
View File
@@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing",
reason: wasActive ? "run_replaced" : "run_started",
});
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
}
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
notifyEmbeddedRunEnded(sessionId);
} else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
+28 -1
View File
@@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) {
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false,
)
.option("--probe", "Probe configured provider auth (live)", false)
.option("--probe-provider <name>", "Only probe a single provider")
.option(
"--probe-profile <id>",
"Only probe specific auth profile ids (repeat or comma-separated)",
(value, previous) => {
const next = Array.isArray(previous) ? previous : previous ? [previous] : [];
next.push(value);
return next;
},
)
.option("--probe-timeout <ms>", "Per-probe timeout in ms")
.option("--probe-concurrency <n>", "Concurrent probes")
.option("--probe-max-tokens <n>", "Probe max tokens (best-effort)")
.action(async (opts) => {
await runModelsCommand(async () => {
await modelsStatusCommand(opts, defaultRuntime);
await modelsStatusCommand(
{
json: Boolean(opts.json),
plain: Boolean(opts.plain),
check: Boolean(opts.check),
probe: Boolean(opts.probe),
probeProvider: opts.probeProvider as string | undefined,
probeProfile: opts.probeProfile as string | string[] | undefined,
probeTimeout: opts.probeTimeout as string | undefined,
probeConcurrency: opts.probeConcurrency as string | undefined,
probeMaxTokens: opts.probeMaxTokens as string | undefined,
},
defaultRuntime,
);
});
});
+1
View File
@@ -17,6 +17,7 @@ const discoverModels = vi.fn();
vi.mock("../config/config.js", () => ({
CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json",
STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state",
loadConfig,
}));
+414
View File
@@ -0,0 +1,414 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
} from "../../agents/auth-profiles.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { describeFailoverError } from "../../agents/failover-error.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionTranscriptPath,
resolveSessionTranscriptsDirForAgent,
} from "../../config/sessions/paths.js";
import { redactSecrets } from "../status-all/format.js";
import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
const PROBE_PROMPT = "Reply with OK. Do not use tools.";
export type AuthProbeStatus =
| "ok"
| "auth"
| "rate_limit"
| "billing"
| "timeout"
| "format"
| "unknown"
| "no_model";
export type AuthProbeResult = {
provider: string;
model?: string;
profileId?: string;
label: string;
source: "profile" | "env" | "models.json";
mode?: string;
status: AuthProbeStatus;
error?: string;
latencyMs?: number;
};
type AuthProbeTarget = {
provider: string;
model?: { provider: string; model: string } | null;
profileId?: string;
label: string;
source: "profile" | "env" | "models.json";
mode?: string;
};
export type AuthProbeSummary = {
startedAt: number;
finishedAt: number;
durationMs: number;
totalTargets: number;
options: {
provider?: string;
profileIds?: string[];
timeoutMs: number;
concurrency: number;
maxTokens: number;
};
results: AuthProbeResult[];
};
export type AuthProbeOptions = {
provider?: string;
profileIds?: string[];
timeoutMs: number;
concurrency: number;
maxTokens: number;
};
const toStatus = (reason?: string | null): AuthProbeStatus => {
if (!reason) return "unknown";
if (reason === "auth") return "auth";
if (reason === "rate_limit") return "rate_limit";
if (reason === "billing") return "billing";
if (reason === "timeout") return "timeout";
if (reason === "format") return "format";
return "unknown";
};
function buildCandidateMap(modelCandidates: string[]): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const raw of modelCandidates) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue;
const list = map.get(parsed.provider) ?? [];
if (!list.includes(parsed.model)) list.push(parsed.model);
map.set(parsed.provider, list);
}
return map;
}
function selectProbeModel(params: {
provider: string;
candidates: Map<string, string[]>;
catalog: Array<{ provider: string; id: string }>;
}): { provider: string; model: string } | null {
const { provider, candidates, catalog } = params;
const direct = candidates.get(provider);
if (direct && direct.length > 0) {
return { provider, model: direct[0] };
}
const fromCatalog = catalog.find((entry) => entry.provider === provider);
if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id };
return null;
}
function buildProbeTargets(params: {
cfg: ClawdbotConfig;
providers: string[];
modelCandidates: string[];
options: AuthProbeOptions;
}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> {
const { cfg, providers, modelCandidates, options } = params;
const store = ensureAuthProfileStore();
const providerFilter = options.provider?.trim();
const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null;
const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean));
return loadModelCatalog({ config: cfg }).then((catalog) => {
const candidates = buildCandidateMap(modelCandidates);
const targets: AuthProbeTarget[] = [];
const results: AuthProbeResult[] = [];
for (const provider of providers) {
const providerKey = normalizeProviderId(provider);
if (providerFilterKey && providerKey !== providerFilterKey) continue;
const model = selectProbeModel({
provider: providerKey,
candidates,
catalog,
});
const profileIds = listProfilesForProvider(store, providerKey);
const filteredProfiles = profileFilter.size
? profileIds.filter((id) => profileFilter.has(id))
: profileIds;
if (filteredProfiles.length > 0) {
for (const profileId of filteredProfiles) {
const profile = store.profiles[profileId];
const mode = profile?.type;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (!model) {
results.push({
provider: providerKey,
model: undefined,
profileId,
label,
source: "profile",
mode,
status: "no_model",
error: "No model available for probe",
});
continue;
}
targets.push({
provider: providerKey,
model,
profileId,
label,
source: "profile",
mode,
});
}
continue;
}
if (profileFilter.size > 0) continue;
const envKey = resolveEnvApiKey(providerKey);
const customKey = getCustomProviderApiKey(cfg, providerKey);
if (!envKey && !customKey) continue;
const label = envKey ? "env" : "models.json";
const source = envKey ? "env" : "models.json";
const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key";
if (!model) {
results.push({
provider: providerKey,
model: undefined,
label,
source,
mode,
status: "no_model",
error: "No model available for probe",
});
continue;
}
targets.push({
provider: providerKey,
model,
label,
source,
mode,
});
}
return { targets, results };
});
}
async function probeTarget(params: {
cfg: ClawdbotConfig;
agentId: string;
agentDir: string;
workspaceDir: string;
sessionDir: string;
target: AuthProbeTarget;
timeoutMs: number;
maxTokens: number;
}): Promise<AuthProbeResult> {
const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params;
if (!target.model) {
return {
provider: target.provider,
model: undefined,
profileId: target.profileId,
label: target.label,
source: target.source,
mode: target.mode,
status: "no_model",
error: "No model available for probe",
};
}
const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`;
const sessionFile = resolveSessionTranscriptPath(sessionId, agentId);
await fs.mkdir(sessionDir, { recursive: true });
const start = Date.now();
try {
await runEmbeddedPiAgent({
sessionId,
sessionFile,
workspaceDir,
agentDir,
config: cfg,
prompt: PROBE_PROMPT,
provider: target.model.provider,
model: target.model.model,
authProfileId: target.profileId,
authProfileIdSource: target.profileId ? "user" : undefined,
timeoutMs,
runId: `probe-${crypto.randomUUID()}`,
lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`,
thinkLevel: "off",
reasoningLevel: "off",
verboseLevel: "off",
streamParams: { maxTokens },
});
return {
provider: target.provider,
model: `${target.model.provider}/${target.model.model}`,
profileId: target.profileId,
label: target.label,
source: target.source,
mode: target.mode,
status: "ok",
latencyMs: Date.now() - start,
};
} catch (err) {
const described = describeFailoverError(err);
return {
provider: target.provider,
model: `${target.model.provider}/${target.model.model}`,
profileId: target.profileId,
label: target.label,
source: target.source,
mode: target.mode,
status: toStatus(described.reason),
error: redactSecrets(described.message),
latencyMs: Date.now() - start,
};
}
}
async function runTargetsWithConcurrency(params: {
cfg: ClawdbotConfig;
targets: AuthProbeTarget[];
timeoutMs: number;
maxTokens: number;
concurrency: number;
onProgress?: (update: { completed: number; total: number; label?: string }) => void;
}): Promise<AuthProbeResult[]> {
const { cfg, targets, timeoutMs, maxTokens, onProgress } = params;
const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency));
const agentId = resolveDefaultAgentId(cfg);
const agentDir = resolveClawdbotAgentDir();
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir();
const sessionDir = resolveSessionTranscriptsDirForAgent(agentId);
await fs.mkdir(workspaceDir, { recursive: true });
let completed = 0;
const results: Array<AuthProbeResult | undefined> = Array.from({ length: targets.length });
let cursor = 0;
const worker = async () => {
while (true) {
const index = cursor;
cursor += 1;
if (index >= targets.length) return;
const target = targets[index];
onProgress?.({
completed,
total: targets.length,
label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`,
});
const result = await probeTarget({
cfg,
agentId,
agentDir,
workspaceDir,
sessionDir,
target,
timeoutMs,
maxTokens,
});
results[index] = result;
completed += 1;
onProgress?.({ completed, total: targets.length });
}
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return results.filter((entry): entry is AuthProbeResult => Boolean(entry));
}
export async function runAuthProbes(params: {
cfg: ClawdbotConfig;
providers: string[];
modelCandidates: string[];
options: AuthProbeOptions;
onProgress?: (update: { completed: number; total: number; label?: string }) => void;
}): Promise<AuthProbeSummary> {
const startedAt = Date.now();
const plan = await buildProbeTargets({
cfg: params.cfg,
providers: params.providers,
modelCandidates: params.modelCandidates,
options: params.options,
});
const totalTargets = plan.targets.length;
params.onProgress?.({ completed: 0, total: totalTargets });
const results = totalTargets
? await runTargetsWithConcurrency({
cfg: params.cfg,
targets: plan.targets,
timeoutMs: params.options.timeoutMs,
maxTokens: params.options.maxTokens,
concurrency: params.options.concurrency,
onProgress: params.onProgress,
})
: [];
const finishedAt = Date.now();
return {
startedAt,
finishedAt,
durationMs: finishedAt - startedAt,
totalTargets,
options: params.options,
results: [...plan.results, ...results],
};
}
export function formatProbeLatency(latencyMs?: number | null) {
if (!latencyMs && latencyMs !== 0) return "-";
return formatMs(latencyMs);
}
export function groupProbeResults(results: AuthProbeResult[]): Map<string, AuthProbeResult[]> {
const map = new Map<string, AuthProbeResult[]>();
for (const result of results) {
const list = map.get(result.provider) ?? [];
list.push(result);
map.set(result.provider, list);
}
return map;
}
export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] {
return results.slice().sort((a, b) => {
const provider = a.provider.localeCompare(b.provider);
if (provider !== 0) return provider;
const aLabel = a.label || a.profileId || "";
const bLabel = b.label || b.profileId || "";
return aLabel.localeCompare(bLabel);
});
}
export function describeProbeSummary(summary: AuthProbeSummary): string {
if (summary.totalTargets === 0) return "No probe targets.";
return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`;
}
+194 -62
View File
@@ -11,9 +11,15 @@ import {
resolveProfileUnusableUntilForDisplay,
} from "../../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../../agents/model-auth.js";
import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js";
import {
buildModelAliasIndex,
parseModelRef,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
import { withProgressTotals } from "../../cli/progress.js";
import {
formatUsageWindowSummary,
loadProviderUsageSummary,
@@ -26,13 +32,34 @@ import { formatCliCommand } from "../../cli/command-format.js";
import { shortenHomePath } from "../../utils.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
import { isRich } from "./list.format.js";
import {
describeProbeSummary,
formatProbeLatency,
groupProbeResults,
runAuthProbes,
sortProbeResults,
type AuthProbeSummary,
} from "./list.probe.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean; check?: boolean },
opts: {
json?: boolean;
plain?: boolean;
check?: boolean;
probe?: boolean;
probeProvider?: string;
probeProfile?: string | string[];
probeTimeout?: string;
probeConcurrency?: string;
probeMaxTokens?: string;
},
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
if (opts.plain && opts.probe) {
throw new Error("--probe cannot be used with --plain output.");
}
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
@@ -139,6 +166,69 @@ export async function modelsStatusCommand(
.filter((provider) => !providerAuthMap.has(provider))
.sort((a, b) => a.localeCompare(b));
const probeProfileIds = (() => {
if (!opts.probeProfile) return [];
const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile];
return raw
.flatMap((value) => String(value ?? "").split(","))
.map((value) => value.trim())
.filter(Boolean);
})();
const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000;
if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) {
throw new Error("--probe-timeout must be a positive number (ms).");
}
const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2;
if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) {
throw new Error("--probe-concurrency must be > 0.");
}
const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8;
if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) {
throw new Error("--probe-max-tokens must be > 0.");
}
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER });
const rawCandidates = [
rawModel || resolvedLabel,
...fallbacks,
imageModel,
...imageFallbacks,
...allowed,
].filter(Boolean);
const resolvedCandidates = rawCandidates
.map(
(raw) =>
resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
})?.ref,
)
.filter((ref): ref is { provider: string; model: string } => Boolean(ref));
const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`);
let probeSummary: AuthProbeSummary | undefined;
if (opts.probe) {
probeSummary = await withProgressTotals(
{ label: "Probing auth profiles…", total: 1 },
async (update) => {
return await runAuthProbes({
cfg,
providers,
modelCandidates,
options: {
provider: opts.probeProvider,
profileIds: probeProfileIds,
timeoutMs: probeTimeoutMs,
concurrency: probeConcurrency,
maxTokens: probeMaxTokens,
},
onProgress: update,
});
},
);
}
const providersWithOauth = providerAuth
.filter(
(entry) =>
@@ -228,6 +318,7 @@ export async function modelsStatusCommand(
profiles: authHealth.profiles,
providers: authHealth.providers,
},
probes: probeSummary,
},
},
null,
@@ -406,72 +497,113 @@ export async function modelsStatusCommand(
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
return;
}
const usageByProvider = new Map<string, string>();
const usageProviders = Array.from(
new Set(
oauthProfiles
.map((profile) => resolveUsageProviderId(profile.provider))
.filter((provider): provider is UsageProviderId => Boolean(provider)),
),
);
if (usageProviders.length > 0) {
try {
const usageSummary = await loadProviderUsageSummary({
providers: usageProviders,
agentDir,
timeoutMs: 3500,
});
for (const snapshot of usageSummary.providers) {
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
} else {
const usageByProvider = new Map<string, string>();
const usageProviders = Array.from(
new Set(
oauthProfiles
.map((profile) => resolveUsageProviderId(profile.provider))
.filter((provider): provider is UsageProviderId => Boolean(provider)),
),
);
if (usageProviders.length > 0) {
try {
const usageSummary = await loadProviderUsageSummary({
providers: usageProviders,
agentDir,
timeoutMs: 3500,
});
if (formatted) {
usageByProvider.set(snapshot.provider, formatted);
for (const snapshot of usageSummary.providers) {
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
});
if (formatted) {
usageByProvider.set(snapshot.provider, formatted);
}
}
} catch {
// ignore usage failures
}
}
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "static") return colorize(rich, theme.muted, "static");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
};
const profilesByProvider = new Map<string, typeof oauthProfiles>();
for (const profile of oauthProfiles) {
const current = profilesByProvider.get(profile.provider);
if (current) current.push(profile);
else profilesByProvider.set(profile.provider, [profile]);
}
for (const [provider, profiles] of profilesByProvider) {
const usageKey = resolveUsageProviderId(provider);
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
for (const profile of profiles) {
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
runtime.log(` - ${label} ${status}${expiry}${source}`);
}
} catch {
// ignore usage failures
}
}
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "static") return colorize(rich, theme.muted, "static");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
};
const profilesByProvider = new Map<string, typeof oauthProfiles>();
for (const profile of oauthProfiles) {
const current = profilesByProvider.get(profile.provider);
if (current) current.push(profile);
else profilesByProvider.set(profile.provider, [profile]);
}
for (const [provider, profiles] of profilesByProvider) {
const usageKey = resolveUsageProviderId(provider);
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
for (const profile of profiles) {
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
runtime.log(` - ${label} ${status}${expiry}${source}`);
if (probeSummary) {
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Auth probes"));
if (probeSummary.results.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
} else {
const grouped = groupProbeResults(sortProbeResults(probeSummary.results));
const statusColor = (status: string) => {
if (status === "ok") return theme.success;
if (status === "rate_limit") return theme.warn;
if (status === "timeout" || status === "billing") return theme.warn;
if (status === "auth" || status === "format") return theme.error;
if (status === "no_model") return theme.muted;
return theme.muted;
};
for (const [provider, results] of grouped) {
const modelLabel = results.find((r) => r.model)?.model ?? "-";
runtime.log(
`- ${theme.heading(provider)}${colorize(
rich,
theme.muted,
modelLabel ? ` (model: ${modelLabel})` : "",
)}`,
);
for (const result of results) {
const status = colorize(rich, statusColor(result.status), result.status);
const latency = formatProbeLatency(result.latencyMs);
const mode = result.mode ? ` (${result.mode})` : "";
const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : "";
runtime.log(
` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize(
rich,
theme.muted,
latency,
)}${detail}`,
);
}
}
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
}
}
+3 -2
View File
@@ -267,9 +267,10 @@ async function withSessionStoreLock<T>(
fn: () => Promise<T>,
opts: SessionStoreLockOptions = {},
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const isFastTest = process.env.CLAWDBOT_TEST_FAST === "1";
const timeoutMs = opts.timeoutMs ?? (isFastTest ? 30_000 : 10_000);
const pollIntervalMs = opts.pollIntervalMs ?? 25;
const staleMs = opts.staleMs ?? 30_000;
const staleMs = opts.staleMs ?? (isFastTest ? 5_000 : 30_000);
const lockPath = `${storePath}.lock`;
const startedAt = Date.now();
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildMinimalServicePath } from "./service-env.js";
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
import { buildMinimalServicePath } from "./service-env.js";
describe("auditGatewayServiceConfig", () => {
it("flags bun runtime", async () => {
+2 -2
View File
@@ -6,7 +6,7 @@ import {
isVersionManagedNodePath,
resolveSystemNodePath,
} from "./runtime-paths.js";
import { getMinimalServicePathParts } from "./service-env.js";
import { getMinimalServicePathPartsFromEnv } from "./service-env.js";
import { resolveSystemdUserUnitPath } from "./systemd.js";
export type GatewayServiceCommand = {
@@ -220,7 +220,7 @@ function auditGatewayServicePath(
return;
}
const expected = getMinimalServicePathParts({ platform, home: env.HOME, env });
const expected = getMinimalServicePathPartsFromEnv({ platform, env });
const parts = servicePath
.split(getPathModule(platform).delimiter)
.map((entry) => entry.trim())
+3 -2
View File
@@ -5,6 +5,7 @@ import {
buildNodeServiceEnvironment,
buildServiceEnvironment,
getMinimalServicePathParts,
getMinimalServicePathPartsFromEnv,
} from "./service-env.js";
describe("getMinimalServicePathParts - Linux user directories", () => {
@@ -71,10 +72,10 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
});
it("includes env-configured bin roots when HOME is set on Linux", () => {
const result = getMinimalServicePathParts({
const result = getMinimalServicePathPartsFromEnv({
platform: "linux",
home: "/home/testuser",
env: {
HOME: "/home/testuser",
PNPM_HOME: "/opt/pnpm",
NPM_CONFIG_PREFIX: "/opt/npm",
BUN_INSTALL: "/opt/bun",
+11 -5
View File
@@ -55,7 +55,7 @@ export function resolveLinuxUserBinDirs(
return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir);
};
// Env-configured bin roots (override defaults when present)
// Env-configured bin roots (override defaults when present).
add(env?.PNPM_HOME);
add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin"));
add(appendSubdir(env?.BUN_INSTALL, "bin"));
@@ -105,6 +105,15 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions =
return parts;
}
export function getMinimalServicePathPartsFromEnv(options: BuildServicePathOptions = {}): string[] {
const env = options.env ?? process.env;
return getMinimalServicePathParts({
...options,
home: options.home ?? env.HOME,
env,
});
}
export function buildMinimalServicePath(options: BuildServicePathOptions = {}): string {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
@@ -112,10 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
return env.PATH ?? "";
}
return getMinimalServicePathParts({
...options,
home: options.home ?? env.HOME,
}).join(path.delimiter);
return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter);
}
export function buildServiceEnvironment(params: {
@@ -4,6 +4,7 @@ import { NonEmptyString } from "./primitives.js";
export const ExecApprovalsAllowlistEntrySchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
pattern: Type.String(),
lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })),
lastUsedCommand: Type.Optional(Type.String()),
+153 -1
View File
@@ -2,9 +2,25 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import { isControlCommandMessage } from "../../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js";
import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js";
import { defaultGroupActivation } from "../../auto-reply/reply/groups.js";
import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js";
import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js";
import {
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
@@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
}
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
@@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionId,
updatedAt: now,
});
store[canonicalKey] = sessionEntry;
const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
@@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
if (isControlCommandMessage(parsedMessage, cfg)) {
try {
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg });
const agentCfg = cfg.agents?.defaults;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
});
const ctx: MsgContext = {
Body: parsedMessage,
CommandBody: parsedMessage,
BodyForCommands: parsedMessage,
CommandSource: "text",
CommandAuthorized: true,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: "tui",
From: p.sessionKey,
To: INTERNAL_MESSAGE_CHANNEL,
SessionKey: p.sessionKey,
ChatType: "direct",
};
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey: p.sessionKey,
isGroup: false,
triggerBodyNormalized: normalizeCommandBody(parsedMessage),
commandAuthorized: true,
});
const directives = parseInlineDirectives(parsedMessage);
const { provider, model } = resolveSessionModelRef(cfg, sessionEntry);
const contextTokens = resolveContextTokens({ agentCfg, model });
const resolveDefaultThinkingLevel = async () => {
const configured = agentCfg?.thinkingDefault;
if (configured) return configured;
const catalog = await context.loadGatewayModelCatalog();
return resolveThinkingDefault({ cfg, provider, model, catalog });
};
const resolvedThinkLevel =
normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ??
(await resolveDefaultThinkingLevel());
const resolvedVerboseLevel =
normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off";
const resolvedReasoningLevel =
normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off";
const resolvedElevatedLevel = normalizeElevatedLevel(
sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault,
);
const elevated = resolveElevatedPermissions({
cfg,
agentId,
ctx,
provider: INTERNAL_MESSAGE_CHANNEL,
});
const commandResult = await handleCommands({
ctx,
cfg,
command,
agentId,
directives,
elevated,
sessionEntry,
previousSessionEntry: entry,
sessionStore: store,
sessionKey: p.sessionKey,
storePath,
sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global",
workspaceDir: workspace.dir,
defaultGroupActivation: () => defaultGroupActivation(true),
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup: false,
});
if (!commandResult.shouldContinue) {
const text = commandResult.reply?.text ?? "";
const message = {
role: "assistant",
content: text.trim() ? [{ type: "text", text }] : [],
timestamp: Date.now(),
command: true,
};
const payload = {
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message,
};
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
payload: { runId: clientRunId, status: "ok" as const },
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
} catch (err) {
const payload = {
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "error" as const,
errorMessage: formatForLog(err),
};
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload: {
runId: clientRunId,
status: "error" as const,
summary: String(err),
},
error,
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
}
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const envelopedMessage = formatInboundEnvelope({
channel: "WebChat",
@@ -259,6 +259,45 @@ describe("gateway server chat", () => {
}
});
test("routes chat.send slash commands without agent runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
try {
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const eventPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-command-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/context list",
idempotencyKey: "idem-command-1",
});
expect(res.ok).toBe(true);
const evt = await eventPromise;
expect(evt.payload?.message?.command).toBe(true);
expect(spy.mock.calls.length).toBe(callsBefore);
} finally {
testState.sessionStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true });
}
});
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
+22 -1
View File
@@ -18,6 +18,7 @@ export type ExecApprovalsDefaults = {
};
export type ExecAllowlistEntry = {
id?: string;
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
@@ -120,6 +121,19 @@ function ensureDir(filePath: string) {
fs.mkdirSync(dir, { recursive: true });
}
function ensureAllowlistIds(
allowlist: ExecAllowlistEntry[] | undefined,
): ExecAllowlistEntry[] | undefined {
if (!Array.isArray(allowlist) || allowlist.length === 0) return allowlist;
let changed = false;
const next = allowlist.map((entry) => {
if (entry.id) return entry;
changed = true;
return { ...entry, id: crypto.randomUUID() };
});
return changed ? next : allowlist;
}
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
@@ -130,6 +144,12 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
delete agents.default;
}
for (const [key, agent] of Object.entries(agents)) {
const allowlist = ensureAllowlistIds(agent.allowlist);
if (allowlist !== agent.allowlist) {
agents[key] = { ...agent, allowlist };
}
}
const normalized: ExecApprovalsFile = {
version: 1,
socket: {
@@ -1145,6 +1165,7 @@ export function recordAllowlistUse(
item.pattern === entry.pattern
? {
...item,
id: item.id ?? crypto.randomUUID(),
lastUsedAt: Date.now(),
lastUsedCommand: command,
lastResolvedPath: resolvedPath,
@@ -1168,7 +1189,7 @@ export function addAllowlistEntry(
const trimmed = pattern.trim();
if (!trimmed) return;
if (allowlist.some((entry) => entry.pattern === trimmed)) return;
allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() });
allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() });
agents[target] = { ...existing, allowlist };
approvals.agents = agents;
saveExecApprovals(approvals);
+1 -1
View File
@@ -39,7 +39,7 @@ export function formatUsageWindowSummary(
snapshot: ProviderUsageSnapshot,
opts?: { now?: number; maxWindows?: number; includeResets?: boolean },
): string | null {
if (snapshot.error) return `error: ${snapshot.error}`;
if (snapshot.error) return null;
if (snapshot.windows.length === 0) return null;
const now = opts?.now ?? Date.now();
const maxWindows =
+10 -7
View File
@@ -197,17 +197,20 @@ export function logSessionStateChange(
},
) {
const state = getSessionState(params);
const isProbeSession = state.sessionId?.startsWith("probe-") ?? false;
const prevState = state.state;
state.state = params.state;
state.lastActivity = Date.now();
if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1);
diag.info(
`session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${
state.sessionKey ?? "unknown"
} prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${
state.queueDepth
}`,
);
if (!isProbeSession) {
diag.info(
`session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${
state.sessionKey ?? "unknown"
} prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${
state.queueDepth
}`,
);
}
emitDiagnosticEvent({
type: "session.state",
sessionId: state.sessionId,
+6 -3
View File
@@ -68,9 +68,12 @@ function drainLane(lane: string) {
entry.resolve(result);
} catch (err) {
state.active -= 1;
diag.error(
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
);
const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-");
if (!isProbeLane) {
diag.error(
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
);
}
pump();
entry.reject(err);
}
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -111,7 +112,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -121,7 +122,11 @@ const getOnHandler = (event: string) => {
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
import { resolveTelegramFetch } from "./fetch.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
@@ -114,7 +116,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -125,7 +127,11 @@ const getOnHandler = (event: string) => {
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -2,8 +2,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -113,7 +114,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
const replyModule = await import("../auto-reply/reply.js");
let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -122,7 +123,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
+13 -7
View File
@@ -6,18 +6,20 @@ import {
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
import { resolveTelegramFetch } from "./fetch.js";
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey;
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
let replyModule: typeof import("../auto-reply/reply.js");
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
vi.mock("../auto-reply/skill-commands.js", () => ({
listSkillCommandsForAgents,
}));
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import * as replyModule from "../auto-reply/reply.js";
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
import { resolveTelegramFetch } from "./fetch.js";
function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
return listSkillCommandsForAgents({ cfg: config });
@@ -155,7 +157,11 @@ const getOnHandler = (event: string) => {
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"));
replyModule = await import("../auto-reply/reply.js");
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
+7 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseCommand } from "./commands.js";
import { getSlashCommands, parseCommand } from "./commands.js";
describe("tui slash commands", () => {
it("treats /elev as an alias for /elevated", () => {
@@ -13,4 +13,10 @@ describe("tui slash commands", () => {
args: "off",
});
});
it("includes gateway text commands", () => {
const commands = getSlashCommands({});
expect(commands.some((command) => command.name === "context")).toBe(true);
expect(commands.some((command) => command.name === "commands")).toBe(true);
});
});
+19 -1
View File
@@ -1,5 +1,7 @@
import type { SlashCommand } from "@mariozechner/pi-tui";
import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/commands-registry.js";
import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/types.js";
const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"];
@@ -13,6 +15,7 @@ export type ParsedCommand = {
};
export type SlashCommandOptions = {
cfg?: ClawdbotConfig;
provider?: string;
model?: string;
};
@@ -34,7 +37,7 @@ export function parseCommand(input: string): ParsedCommand {
export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] {
const thinkLevels = listThinkingLevelLabels(options.provider, options.model);
return [
const commands: SlashCommand[] = [
{ name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" },
{ name: "agent", description: "Switch agent (or open picker)" },
@@ -115,6 +118,20 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
{ name: "exit", description: "Exit the TUI" },
{ name: "quit", description: "Exit the TUI" },
];
const seen = new Set(commands.map((command) => command.name));
const gatewayCommands = options.cfg ? listChatCommandsForConfig(options.cfg) : listChatCommands();
for (const command of gatewayCommands) {
const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`];
for (const alias of aliases) {
const name = alias.replace(/^\//, "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
commands.push({ name, description: command.description });
}
}
return commands;
}
export function helpText(options: SlashCommandOptions = {}): string {
@@ -122,6 +139,7 @@ export function helpText(options: SlashCommandOptions = {}): string {
return [
"Slash commands:",
"/help",
"/commands",
"/status",
"/agent <id> (or /agents)",
"/session <key> (or /sessions)",
+12 -1
View File
@@ -1,6 +1,6 @@
import type { TUI } from "@mariozechner/pi-tui";
import type { ChatLog } from "./components/chat-log.js";
import { asString } from "./tui-formatters.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import { TuiStreamAssembler } from "./tui-stream-assembler.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
@@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) {
setActivityStatus("streaming");
}
if (evt.state === "final") {
if (isCommandMessage(evt.message)) {
const text = extractTextFromMessage(evt.message);
if (text) chatLog.addSystem(text);
streamAssembler.drop(evt.runId);
noteFinalizedRun(evt.runId);
state.activeChatRunId = null;
setActivityStatus("idle");
void refreshSessionInfo?.();
tui.requestRender();
return;
}
const stopReason =
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
? typeof (evt.message as Record<string, unknown>).stopReason === "string"
+9
View File
@@ -4,6 +4,7 @@ import {
extractContentFromMessage,
extractTextFromMessage,
extractThinkingFromMessage,
isCommandMessage,
} from "./tui-formatters.js";
describe("extractTextFromMessage", () => {
@@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => {
expect(text).toContain("HTTP 429");
});
});
describe("isCommandMessage", () => {
it("detects command-marked messages", () => {
expect(isCommandMessage({ command: true })).toBe(true);
expect(isCommandMessage({ command: false })).toBe(false);
expect(isCommandMessage({})).toBe(false);
});
});
+5
View File
@@ -140,6 +140,11 @@ export function extractTextFromMessage(
return formatRawAssistantErrorForUi(errorMessage);
}
export function isCommandMessage(message: unknown): boolean {
if (!message || typeof message !== "object") return false;
return (message as Record<string, unknown>).command === true;
}
export function formatTokens(total?: number | null, context?: number | null) {
if (total == null && context == null) return "tokens ?";
const totalLabel = total == null ? "?" : formatTokenCount(total);
+6 -1
View File
@@ -6,7 +6,7 @@ import {
} from "../routing/session-key.js";
import type { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import { asString, extractTextFromMessage } from "./tui-formatters.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionContext = {
@@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) {
for (const entry of record.messages ?? []) {
if (!entry || typeof entry !== "object") continue;
const message = entry as Record<string, unknown>;
if (isCommandMessage(message)) {
const text = extractTextFromMessage(message);
if (text) chatLog.addSystem(text);
continue;
}
if (message.role === "user") {
const text = extractTextFromMessage(message);
if (text) chatLog.addUser(text);
+1
View File
@@ -245,6 +245,7 @@ export async function runTui(opts: TuiOptions) {
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
getSlashCommands({
cfg: config,
provider: sessionInfo.modelProvider,
model: sessionInfo.model,
}),
+2 -2
View File
@@ -9,9 +9,9 @@ import type { ClawdbotConfig } from "../src/config/config.js";
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
import { createTestRegistry } from "../src/test-utils/channel-plugins.js";
import { installTestEnv } from "./test-env";
import { withIsolatedTestHome } from "./test-env";
const testEnv = installTestEnv();
const testEnv = withIsolatedTestHome();
afterAll(() => testEnv.cleanup());
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
switch (id) {
+4
View File
@@ -130,3 +130,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
return { cleanup, tempHome };
}
export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } {
return installTestEnv();
}
+1
View File
@@ -9,6 +9,7 @@ export type ExecApprovalsDefaults = {
};
export type ExecApprovalsAllowlistEntry = {
id?: string;
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;