Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fbf728298 | ||
|
|
16c4a3aea6 | ||
|
|
242add587f | ||
|
|
6fba598eaf | ||
|
|
75a54f0259 | ||
|
|
c63144ab14 | ||
|
|
f07c39b265 | ||
|
|
40181afded | ||
|
|
2f1b9efe9a | ||
|
|
ff30cef8a4 | ||
|
|
3d958d5466 | ||
|
|
cad7ed1cb8 | ||
|
|
8195497cec |
No files matched your search
@@ -128,6 +128,10 @@
|
|||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
- **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:** 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.
|
- **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.
|
- 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.
|
- **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.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
|
|||||||
+8
-1
@@ -6,13 +6,20 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
- 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.
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
|
||||||
### Fixes
|
### 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: 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.
|
- 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)
|
- 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
|
## 2026.1.22
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
|||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecAllowlistEntry: Codable, Hashable {
|
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
var pattern: String
|
var pattern: String
|
||||||
var lastUsedAt: Double?
|
var lastUsedAt: Double?
|
||||||
var lastUsedCommand: String?
|
var lastUsedCommand: String?
|
||||||
var lastResolvedPath: 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 {
|
struct ExecApprovalsDefaults: Codable {
|
||||||
@@ -295,6 +336,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: entry.id,
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: entry.lastUsedAt,
|
lastUsedAt: entry.lastUsedAt,
|
||||||
lastUsedCommand: entry.lastUsedCommand,
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
@@ -379,6 +421,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
guard item.pattern == pattern else { return item }
|
guard item.pattern == pattern else { return item }
|
||||||
return ExecAllowlistEntry(
|
return ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern,
|
pattern: item.pattern,
|
||||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
@@ -398,6 +441,7 @@ enum ExecApprovalsStore {
|
|||||||
let cleaned = allowlist
|
let cleaned = allowlist
|
||||||
.map { item in
|
.map { item in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: item.lastUsedAt,
|
lastUsedAt: item.lastUsedAt,
|
||||||
lastUsedCommand: item.lastUsedCommand,
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
|
|||||||
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
ForEach(self.model.entries, id: \.id) { entry in
|
||||||
ExecAllowlistRow(
|
ExecAllowlistRow(
|
||||||
entry: Binding(
|
entry: Binding(
|
||||||
get: { self.model.entries[index] },
|
get: { self.model.entry(for: entry.id) ?? entry },
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
set: { self.model.updateEntry($0, id: entry.id) }),
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
onRemove: { self.model.removeEntry(id: entry.id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
|
|||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
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.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
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(id: UUID) {
|
||||||
guard !self.isDefaultsScope else { return }
|
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)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
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 {
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
guard self.autoAllowSkills else {
|
guard self.autoAllowSkills else {
|
||||||
self.skillBins = []
|
self.skillBins = []
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ struct VoiceWakeSettings: View {
|
|||||||
@State private var micObserver = AudioInputDeviceObserver()
|
@State private var micObserver = AudioInputDeviceObserver()
|
||||||
@State private var micRefreshTask: Task<Void, Never>?
|
@State private var micRefreshTask: Task<Void, Never>?
|
||||||
@State private var availableLocales: [Locale] = []
|
@State private var availableLocales: [Locale] = []
|
||||||
|
@State private var triggerEntries: [TriggerEntry] = []
|
||||||
|
@FocusState private var focusedTriggerEntryID: UUID?
|
||||||
private let fieldLabelWidth: CGFloat = 140
|
private let fieldLabelWidth: CGFloat = 140
|
||||||
private let controlWidth: CGFloat = 240
|
private let controlWidth: CGFloat = 240
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
@@ -31,9 +33,9 @@ struct VoiceWakeSettings: View {
|
|||||||
var id: String { self.uid }
|
var id: String { self.uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct IndexedWord: Identifiable {
|
private struct TriggerEntry: Identifiable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let value: String
|
var value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private var voiceWakeBinding: Binding<Bool> {
|
private var voiceWakeBinding: Binding<Bool> {
|
||||||
@@ -105,6 +107,7 @@ struct VoiceWakeSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -122,8 +125,10 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
} else {
|
} else {
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -136,11 +141,21 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
Task { await self.meter.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] {
|
private func loadTriggerEntries() {
|
||||||
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
|
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 {
|
private var triggerTable: some View {
|
||||||
@@ -154,29 +169,47 @@ struct VoiceWakeSettings: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Add word", systemImage: "plus")
|
Label("Add word", systemImage: "plus")
|
||||||
}
|
}
|
||||||
.disabled(self.state.swabbleTriggerWords
|
.disabled(self.triggerEntries
|
||||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
.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) {
|
VStack(spacing: 0) {
|
||||||
TableColumn("Word") { row in
|
ForEach(self.$triggerEntries) { $entry in
|
||||||
TextField("Wake word", text: self.binding(for: row.id))
|
HStack(spacing: 8) {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("Wake word", text: $entry.value)
|
||||||
}
|
.textFieldStyle(.roundedBorder)
|
||||||
TableColumn("") { row in
|
.focused(self.$focusedTriggerEntryID, equals: entry.id)
|
||||||
Button {
|
.onSubmit {
|
||||||
self.removeWord(at: row.id)
|
self.syncTriggerEntriesToState()
|
||||||
} label: {
|
}
|
||||||
Image(systemName: "trash")
|
|
||||||
|
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))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
@@ -211,24 +244,12 @@ struct VoiceWakeSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addWord() {
|
private func addWord() {
|
||||||
self.state.swabbleTriggerWords.append("")
|
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeWord(at index: Int) {
|
private func removeWord(id: UUID) {
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
self.triggerEntries.removeAll { $0.id == id }
|
||||||
self.state.swabbleTriggerWords.remove(at: index)
|
self.syncTriggerEntriesToState()
|
||||||
}
|
|
||||||
|
|
||||||
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 toggleTest() {
|
private func toggleTest() {
|
||||||
@@ -638,13 +659,14 @@ extension VoiceWakeSettings {
|
|||||||
state.voicePushToTalkEnabled = true
|
state.voicePushToTalkEnabled = true
|
||||||
state.swabbleTriggerWords = ["Claude", "Hey"]
|
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.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
||||||
view.availableLocales = [Locale(identifier: "en_US")]
|
view.availableLocales = [Locale(identifier: "en_US")]
|
||||||
view.meterLevel = 0.42
|
view.meterLevel = 0.42
|
||||||
view.meterError = "No input"
|
view.meterError = "No input"
|
||||||
view.testState = .detected("ok")
|
view.testState = .detected("ok")
|
||||||
view.isTesting = true
|
view.isTesting = true
|
||||||
|
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
|
||||||
|
|
||||||
_ = view.body
|
_ = view.body
|
||||||
_ = view.localePicker
|
_ = view.localePicker
|
||||||
@@ -654,8 +676,9 @@ extension VoiceWakeSettings {
|
|||||||
_ = view.chimeSection
|
_ = view.chimeSection
|
||||||
|
|
||||||
view.addWord()
|
view.addWord()
|
||||||
_ = view.binding(for: 0).wrappedValue
|
if let entryId = view.triggerEntries.first?.id {
|
||||||
view.removeWord(at: 0)
|
view.removeWord(id: entryId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -700,8 +700,15 @@ Options:
|
|||||||
- `--json`
|
- `--json`
|
||||||
- `--plain`
|
- `--plain`
|
||||||
- `--check` (exit 1=expired/missing, 2=expiring)
|
- `--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.
|
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>`
|
### `models set <model>`
|
||||||
Set `agents.defaults.model.primary`.
|
Set `agents.defaults.model.primary`.
|
||||||
|
|||||||
@@ -25,12 +25,26 @@ clawdbot models scan
|
|||||||
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
||||||
When provider usage snapshots are available, the OAuth/token status section includes
|
When provider usage snapshots are available, the OAuth/token status section includes
|
||||||
provider usage headers.
|
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:
|
Notes:
|
||||||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
- `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`).
|
- 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).
|
- 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
|
## Aliases + fallbacks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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.
|
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?
|
### Can I switch between npm and git installs later?
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Example schema:
|
|||||||
"autoAllowSkills": true,
|
"autoAllowSkills": true,
|
||||||
"allowlist": [
|
"allowlist": [
|
||||||
{
|
{
|
||||||
|
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
|
||||||
"pattern": "~/Projects/**/bin/rg",
|
"pattern": "~/Projects/**/bin/rg",
|
||||||
"lastUsedAt": 1737150000000,
|
"lastUsedAt": 1737150000000,
|
||||||
"lastUsedCommand": "rg -n TODO",
|
"lastUsedCommand": "rg -n TODO",
|
||||||
@@ -96,6 +97,7 @@ Examples:
|
|||||||
- `/opt/homebrew/bin/rg`
|
- `/opt/homebrew/bin/rg`
|
||||||
|
|
||||||
Each allowlist entry tracks:
|
Each allowlist entry tracks:
|
||||||
|
- **id** stable UUID used for UI identity (optional)
|
||||||
- **last used** timestamp
|
- **last used** timestamp
|
||||||
- **last used command**
|
- **last used command**
|
||||||
- **last resolved path**
|
- **last resolved path**
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ Session lifecycle:
|
|||||||
- `/settings`
|
- `/settings`
|
||||||
- `/exit`
|
- `/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
|
## Local shell commands
|
||||||
- Prefix a line with `!` to run a local shell command on the TUI host.
|
- 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.
|
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
|
||||||
|
|||||||
@@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
|
|||||||
state: "processing",
|
state: "processing",
|
||||||
reason: wasActive ? "run_replaced" : "run_started",
|
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) {
|
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
|
||||||
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
|
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
|
||||||
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
|
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
|
||||||
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
|
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);
|
notifyEmbeddedRunEnded(sessionId);
|
||||||
} else {
|
} else {
|
||||||
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
|
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
|
||||||
|
|||||||
+28
-1
@@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) {
|
|||||||
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
|
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
|
||||||
false,
|
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) => {
|
.action(async (opts) => {
|
||||||
await runModelsCommand(async () => {
|
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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const discoverModels = vi.fn();
|
|||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json",
|
CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json",
|
||||||
|
STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state",
|
||||||
loadConfig,
|
loadConfig,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -11,9 +11,15 @@ import {
|
|||||||
resolveProfileUnusableUntilForDisplay,
|
resolveProfileUnusableUntilForDisplay,
|
||||||
} from "../../agents/auth-profiles.js";
|
} from "../../agents/auth-profiles.js";
|
||||||
import { resolveEnvApiKey } from "../../agents/model-auth.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 { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||||
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
|
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
|
||||||
|
import { withProgressTotals } from "../../cli/progress.js";
|
||||||
import {
|
import {
|
||||||
formatUsageWindowSummary,
|
formatUsageWindowSummary,
|
||||||
loadProviderUsageSummary,
|
loadProviderUsageSummary,
|
||||||
@@ -26,13 +32,34 @@ import { formatCliCommand } from "../../cli/command-format.js";
|
|||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
||||||
import { isRich } from "./list.format.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";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
|
||||||
|
|
||||||
export async function modelsStatusCommand(
|
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,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
ensureFlagCompatibility(opts);
|
ensureFlagCompatibility(opts);
|
||||||
|
if (opts.plain && opts.probe) {
|
||||||
|
throw new Error("--probe cannot be used with --plain output.");
|
||||||
|
}
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveConfiguredModelRef({
|
const resolved = resolveConfiguredModelRef({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -139,6 +166,69 @@ export async function modelsStatusCommand(
|
|||||||
.filter((provider) => !providerAuthMap.has(provider))
|
.filter((provider) => !providerAuthMap.has(provider))
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.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
|
const providersWithOauth = providerAuth
|
||||||
.filter(
|
.filter(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
@@ -228,6 +318,7 @@ export async function modelsStatusCommand(
|
|||||||
profiles: authHealth.profiles,
|
profiles: authHealth.profiles,
|
||||||
providers: authHealth.providers,
|
providers: authHealth.providers,
|
||||||
},
|
},
|
||||||
|
probes: probeSummary,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -406,72 +497,113 @@ export async function modelsStatusCommand(
|
|||||||
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
|
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
|
||||||
if (oauthProfiles.length === 0) {
|
if (oauthProfiles.length === 0) {
|
||||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||||
return;
|
} else {
|
||||||
}
|
const usageByProvider = new Map<string, string>();
|
||||||
|
const usageProviders = Array.from(
|
||||||
const usageByProvider = new Map<string, string>();
|
new Set(
|
||||||
const usageProviders = Array.from(
|
oauthProfiles
|
||||||
new Set(
|
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||||
oauthProfiles
|
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||||
.map((profile) => resolveUsageProviderId(profile.provider))
|
),
|
||||||
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
);
|
||||||
),
|
if (usageProviders.length > 0) {
|
||||||
);
|
try {
|
||||||
if (usageProviders.length > 0) {
|
const usageSummary = await loadProviderUsageSummary({
|
||||||
try {
|
providers: usageProviders,
|
||||||
const usageSummary = await loadProviderUsageSummary({
|
agentDir,
|
||||||
providers: usageProviders,
|
timeoutMs: 3500,
|
||||||
agentDir,
|
|
||||||
timeoutMs: 3500,
|
|
||||||
});
|
|
||||||
for (const snapshot of usageSummary.providers) {
|
|
||||||
const formatted = formatUsageWindowSummary(snapshot, {
|
|
||||||
now: Date.now(),
|
|
||||||
maxWindows: 2,
|
|
||||||
includeResets: true,
|
|
||||||
});
|
});
|
||||||
if (formatted) {
|
for (const snapshot of usageSummary.providers) {
|
||||||
usageByProvider.set(snapshot.provider, formatted);
|
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 (probeSummary) {
|
||||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
runtime.log("");
|
||||||
if (status === "static") return colorize(rich, theme.muted, "static");
|
runtime.log(colorize(rich, theme.heading, "Auth probes"));
|
||||||
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
|
if (probeSummary.results.length === 0) {
|
||||||
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||||
return colorize(rich, theme.error, "expired");
|
} else {
|
||||||
};
|
const grouped = groupProbeResults(sortProbeResults(probeSummary.results));
|
||||||
|
const statusColor = (status: string) => {
|
||||||
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
if (status === "ok") return theme.success;
|
||||||
for (const profile of oauthProfiles) {
|
if (status === "rate_limit") return theme.warn;
|
||||||
const current = profilesByProvider.get(profile.provider);
|
if (status === "timeout" || status === "billing") return theme.warn;
|
||||||
if (current) current.push(profile);
|
if (status === "auth" || status === "format") return theme.error;
|
||||||
else profilesByProvider.set(profile.provider, [profile]);
|
if (status === "no_model") return theme.muted;
|
||||||
}
|
return theme.muted;
|
||||||
|
};
|
||||||
for (const [provider, profiles] of profilesByProvider) {
|
for (const [provider, results] of grouped) {
|
||||||
const usageKey = resolveUsageProviderId(provider);
|
const modelLabel = results.find((r) => r.model)?.model ?? "-";
|
||||||
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
runtime.log(
|
||||||
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
|
`- ${theme.heading(provider)}${colorize(
|
||||||
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
|
rich,
|
||||||
for (const profile of profiles) {
|
theme.muted,
|
||||||
const labelText = profile.label || profile.profileId;
|
modelLabel ? ` (model: ${modelLabel})` : "",
|
||||||
const label = colorize(rich, theme.accent, labelText);
|
)}`,
|
||||||
const status = formatStatus(profile.status);
|
);
|
||||||
const expiry =
|
for (const result of results) {
|
||||||
profile.status === "static"
|
const status = colorize(rich, statusColor(result.status), result.status);
|
||||||
? ""
|
const latency = formatProbeLatency(result.latencyMs);
|
||||||
: profile.expiresAt
|
const mode = result.mode ? ` (${result.mode})` : "";
|
||||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : "";
|
||||||
: " expires unknown";
|
runtime.log(
|
||||||
const source =
|
` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize(
|
||||||
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
|
rich,
|
||||||
runtime.log(` - ${label} ${status}${expiry}${source}`);
|
theme.muted,
|
||||||
|
latency,
|
||||||
|
)}${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,9 +267,10 @@ async function withSessionStoreLock<T>(
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
opts: SessionStoreLockOptions = {},
|
opts: SessionStoreLockOptions = {},
|
||||||
): Promise<T> {
|
): 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 pollIntervalMs = opts.pollIntervalMs ?? 25;
|
||||||
const staleMs = opts.staleMs ?? 30_000;
|
const staleMs = opts.staleMs ?? (isFastTest ? 5_000 : 30_000);
|
||||||
const lockPath = `${storePath}.lock`;
|
const lockPath = `${storePath}.lock`;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildMinimalServicePath } from "./service-env.js";
|
|
||||||
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
|
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
|
||||||
|
import { buildMinimalServicePath } from "./service-env.js";
|
||||||
|
|
||||||
describe("auditGatewayServiceConfig", () => {
|
describe("auditGatewayServiceConfig", () => {
|
||||||
it("flags bun runtime", async () => {
|
it("flags bun runtime", async () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
isVersionManagedNodePath,
|
isVersionManagedNodePath,
|
||||||
resolveSystemNodePath,
|
resolveSystemNodePath,
|
||||||
} from "./runtime-paths.js";
|
} from "./runtime-paths.js";
|
||||||
import { getMinimalServicePathParts } from "./service-env.js";
|
import { getMinimalServicePathPartsFromEnv } from "./service-env.js";
|
||||||
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
||||||
|
|
||||||
export type GatewayServiceCommand = {
|
export type GatewayServiceCommand = {
|
||||||
@@ -220,7 +220,7 @@ function auditGatewayServicePath(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expected = getMinimalServicePathParts({ platform, home: env.HOME, env });
|
const expected = getMinimalServicePathPartsFromEnv({ platform, env });
|
||||||
const parts = servicePath
|
const parts = servicePath
|
||||||
.split(getPathModule(platform).delimiter)
|
.split(getPathModule(platform).delimiter)
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildNodeServiceEnvironment,
|
buildNodeServiceEnvironment,
|
||||||
buildServiceEnvironment,
|
buildServiceEnvironment,
|
||||||
getMinimalServicePathParts,
|
getMinimalServicePathParts,
|
||||||
|
getMinimalServicePathPartsFromEnv,
|
||||||
} from "./service-env.js";
|
} from "./service-env.js";
|
||||||
|
|
||||||
describe("getMinimalServicePathParts - Linux user directories", () => {
|
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", () => {
|
it("includes env-configured bin roots when HOME is set on Linux", () => {
|
||||||
const result = getMinimalServicePathParts({
|
const result = getMinimalServicePathPartsFromEnv({
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
home: "/home/testuser",
|
|
||||||
env: {
|
env: {
|
||||||
|
HOME: "/home/testuser",
|
||||||
PNPM_HOME: "/opt/pnpm",
|
PNPM_HOME: "/opt/pnpm",
|
||||||
NPM_CONFIG_PREFIX: "/opt/npm",
|
NPM_CONFIG_PREFIX: "/opt/npm",
|
||||||
BUN_INSTALL: "/opt/bun",
|
BUN_INSTALL: "/opt/bun",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function resolveLinuxUserBinDirs(
|
|||||||
return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir);
|
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(env?.PNPM_HOME);
|
||||||
add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin"));
|
add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin"));
|
||||||
add(appendSubdir(env?.BUN_INSTALL, "bin"));
|
add(appendSubdir(env?.BUN_INSTALL, "bin"));
|
||||||
@@ -105,6 +105,15 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions =
|
|||||||
return parts;
|
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 {
|
export function buildMinimalServicePath(options: BuildServicePathOptions = {}): string {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
@@ -112,10 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
|
|||||||
return env.PATH ?? "";
|
return env.PATH ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return getMinimalServicePathParts({
|
return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter);
|
||||||
...options,
|
|
||||||
home: options.home ?? env.HOME,
|
|
||||||
}).join(path.delimiter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildServiceEnvironment(params: {
|
export function buildServiceEnvironment(params: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NonEmptyString } from "./primitives.js";
|
|||||||
|
|
||||||
export const ExecApprovalsAllowlistEntrySchema = Type.Object(
|
export const ExecApprovalsAllowlistEntrySchema = Type.Object(
|
||||||
{
|
{
|
||||||
|
id: Type.Optional(NonEmptyString),
|
||||||
pattern: Type.String(),
|
pattern: Type.String(),
|
||||||
lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })),
|
lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
lastUsedCommand: Type.Optional(Type.String()),
|
lastUsedCommand: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -2,9 +2,25 @@ import { randomUUID } from "node:crypto";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.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 { 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 { agentCommand } from "../../commands/agent.js";
|
||||||
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
@@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
|
||||||
const timeoutMs = resolveAgentTimeoutMs({
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
cfg,
|
cfg,
|
||||||
overrideMs: p.timeoutMs,
|
overrideMs: p.timeoutMs,
|
||||||
@@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
store[canonicalKey] = sessionEntry;
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||||
|
|
||||||
@@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
};
|
};
|
||||||
respond(true, ackPayload, undefined, { runId: clientRunId });
|
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 envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
const envelopedMessage = formatInboundEnvelope({
|
const envelopedMessage = formatInboundEnvelope({
|
||||||
channel: "WebChat",
|
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 () => {
|
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ExecApprovalsDefaults = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecAllowlistEntry = {
|
export type ExecAllowlistEntry = {
|
||||||
|
id?: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
lastUsedCommand?: string;
|
lastUsedCommand?: string;
|
||||||
@@ -120,6 +121,19 @@ function ensureDir(filePath: string) {
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
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 {
|
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||||
const socketPath = file.socket?.path?.trim();
|
const socketPath = file.socket?.path?.trim();
|
||||||
const token = file.socket?.token?.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;
|
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
|
||||||
delete agents.default;
|
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 = {
|
const normalized: ExecApprovalsFile = {
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: {
|
socket: {
|
||||||
@@ -1145,6 +1165,7 @@ export function recordAllowlistUse(
|
|||||||
item.pattern === entry.pattern
|
item.pattern === entry.pattern
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
|
id: item.id ?? crypto.randomUUID(),
|
||||||
lastUsedAt: Date.now(),
|
lastUsedAt: Date.now(),
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
lastResolvedPath: resolvedPath,
|
lastResolvedPath: resolvedPath,
|
||||||
@@ -1168,7 +1189,7 @@ export function addAllowlistEntry(
|
|||||||
const trimmed = pattern.trim();
|
const trimmed = pattern.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
if (allowlist.some((entry) => entry.pattern === 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 };
|
agents[target] = { ...existing, allowlist };
|
||||||
approvals.agents = agents;
|
approvals.agents = agents;
|
||||||
saveExecApprovals(approvals);
|
saveExecApprovals(approvals);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function formatUsageWindowSummary(
|
|||||||
snapshot: ProviderUsageSnapshot,
|
snapshot: ProviderUsageSnapshot,
|
||||||
opts?: { now?: number; maxWindows?: number; includeResets?: boolean },
|
opts?: { now?: number; maxWindows?: number; includeResets?: boolean },
|
||||||
): string | null {
|
): string | null {
|
||||||
if (snapshot.error) return `error: ${snapshot.error}`;
|
if (snapshot.error) return null;
|
||||||
if (snapshot.windows.length === 0) return null;
|
if (snapshot.windows.length === 0) return null;
|
||||||
const now = opts?.now ?? Date.now();
|
const now = opts?.now ?? Date.now();
|
||||||
const maxWindows =
|
const maxWindows =
|
||||||
|
|||||||
@@ -197,17 +197,20 @@ export function logSessionStateChange(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const state = getSessionState(params);
|
const state = getSessionState(params);
|
||||||
|
const isProbeSession = state.sessionId?.startsWith("probe-") ?? false;
|
||||||
const prevState = state.state;
|
const prevState = state.state;
|
||||||
state.state = params.state;
|
state.state = params.state;
|
||||||
state.lastActivity = Date.now();
|
state.lastActivity = Date.now();
|
||||||
if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1);
|
if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1);
|
||||||
diag.info(
|
if (!isProbeSession) {
|
||||||
`session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${
|
diag.info(
|
||||||
state.sessionKey ?? "unknown"
|
`session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${
|
||||||
} prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${
|
state.sessionKey ?? "unknown"
|
||||||
state.queueDepth
|
} prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${
|
||||||
}`,
|
state.queueDepth
|
||||||
);
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
emitDiagnosticEvent({
|
emitDiagnosticEvent({
|
||||||
type: "session.state",
|
type: "session.state",
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
|
|||||||
@@ -68,9 +68,12 @@ function drainLane(lane: string) {
|
|||||||
entry.resolve(result);
|
entry.resolve(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.active -= 1;
|
state.active -= 1;
|
||||||
diag.error(
|
const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-");
|
||||||
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
|
if (!isProbeLane) {
|
||||||
);
|
diag.error(
|
||||||
|
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
pump();
|
pump();
|
||||||
entry.reject(err);
|
entry.reject(err);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -1,7 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -111,7 +112,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
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;
|
const ORIGINAL_TZ = process.env.TZ;
|
||||||
describe("createTelegramBot", () => {
|
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";
|
process.env.TZ = "UTC";
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
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";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -114,7 +116,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
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;
|
const ORIGINAL_TZ = process.env.TZ;
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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";
|
process.env.TZ = "UTC";
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -119,7 +120,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
+9
-4
@@ -2,8 +2,9 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
loadWebMedia: vi.fn(),
|
loadWebMedia: vi.fn(),
|
||||||
@@ -113,7 +114,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
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 getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@@ -122,7 +123,11 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
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();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import {
|
|||||||
listNativeCommandSpecs,
|
listNativeCommandSpecs,
|
||||||
listNativeCommandSpecsForConfig,
|
listNativeCommandSpecsForConfig,
|
||||||
} from "../auto-reply/commands-registry.js";
|
} 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(() => ({
|
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
|
||||||
listSkillCommandsForAgents: vi.fn(() => []),
|
listSkillCommandsForAgents: vi.fn(() => []),
|
||||||
}));
|
}));
|
||||||
vi.mock("../auto-reply/skill-commands.js", () => ({
|
vi.mock("../auto-reply/skill-commands.js", () => ({
|
||||||
listSkillCommandsForAgents,
|
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]) {
|
function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
|
||||||
return listSkillCommandsForAgents({ cfg: config });
|
return listSkillCommandsForAgents({ cfg: config });
|
||||||
@@ -155,7 +157,11 @@ const getOnHandler = (event: string) => {
|
|||||||
|
|
||||||
const ORIGINAL_TZ = process.env.TZ;
|
const ORIGINAL_TZ = process.env.TZ;
|
||||||
describe("createTelegramBot", () => {
|
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";
|
process.env.TZ = "UTC";
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { parseCommand } from "./commands.js";
|
import { getSlashCommands, parseCommand } from "./commands.js";
|
||||||
|
|
||||||
describe("tui slash commands", () => {
|
describe("tui slash commands", () => {
|
||||||
it("treats /elev as an alias for /elevated", () => {
|
it("treats /elev as an alias for /elevated", () => {
|
||||||
@@ -13,4 +13,10 @@ describe("tui slash commands", () => {
|
|||||||
args: "off",
|
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
@@ -1,5 +1,7 @@
|
|||||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
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 { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/types.js";
|
||||||
|
|
||||||
const VERBOSE_LEVELS = ["on", "off"];
|
const VERBOSE_LEVELS = ["on", "off"];
|
||||||
const REASONING_LEVELS = ["on", "off"];
|
const REASONING_LEVELS = ["on", "off"];
|
||||||
@@ -13,6 +15,7 @@ export type ParsedCommand = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SlashCommandOptions = {
|
export type SlashCommandOptions = {
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
@@ -34,7 +37,7 @@ export function parseCommand(input: string): ParsedCommand {
|
|||||||
|
|
||||||
export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] {
|
export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] {
|
||||||
const thinkLevels = listThinkingLevelLabels(options.provider, options.model);
|
const thinkLevels = listThinkingLevelLabels(options.provider, options.model);
|
||||||
return [
|
const commands: SlashCommand[] = [
|
||||||
{ name: "help", description: "Show slash command help" },
|
{ name: "help", description: "Show slash command help" },
|
||||||
{ name: "status", description: "Show gateway status summary" },
|
{ name: "status", description: "Show gateway status summary" },
|
||||||
{ name: "agent", description: "Switch agent (or open picker)" },
|
{ 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: "exit", description: "Exit the TUI" },
|
||||||
{ name: "quit", 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 {
|
export function helpText(options: SlashCommandOptions = {}): string {
|
||||||
@@ -122,6 +139,7 @@ export function helpText(options: SlashCommandOptions = {}): string {
|
|||||||
return [
|
return [
|
||||||
"Slash commands:",
|
"Slash commands:",
|
||||||
"/help",
|
"/help",
|
||||||
|
"/commands",
|
||||||
"/status",
|
"/status",
|
||||||
"/agent <id> (or /agents)",
|
"/agent <id> (or /agents)",
|
||||||
"/session <key> (or /sessions)",
|
"/session <key> (or /sessions)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TUI } from "@mariozechner/pi-tui";
|
import type { TUI } from "@mariozechner/pi-tui";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
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 { TuiStreamAssembler } from "./tui-stream-assembler.js";
|
||||||
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||||
|
|
||||||
@@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||||||
setActivityStatus("streaming");
|
setActivityStatus("streaming");
|
||||||
}
|
}
|
||||||
if (evt.state === "final") {
|
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 =
|
const stopReason =
|
||||||
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
|
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
|
||||||
? typeof (evt.message as Record<string, unknown>).stopReason === "string"
|
? typeof (evt.message as Record<string, unknown>).stopReason === "string"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractTextFromMessage,
|
extractTextFromMessage,
|
||||||
extractThinkingFromMessage,
|
extractThinkingFromMessage,
|
||||||
|
isCommandMessage,
|
||||||
} from "./tui-formatters.js";
|
} from "./tui-formatters.js";
|
||||||
|
|
||||||
describe("extractTextFromMessage", () => {
|
describe("extractTextFromMessage", () => {
|
||||||
@@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => {
|
|||||||
expect(text).toContain("HTTP 429");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,6 +140,11 @@ export function extractTextFromMessage(
|
|||||||
return formatRawAssistantErrorForUi(errorMessage);
|
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) {
|
export function formatTokens(total?: number | null, context?: number | null) {
|
||||||
if (total == null && context == null) return "tokens ?";
|
if (total == null && context == null) return "tokens ?";
|
||||||
const totalLabel = total == null ? "?" : formatTokenCount(total);
|
const totalLabel = total == null ? "?" : formatTokenCount(total);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.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";
|
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
|
||||||
|
|
||||||
type SessionActionContext = {
|
type SessionActionContext = {
|
||||||
@@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) {
|
|||||||
for (const entry of record.messages ?? []) {
|
for (const entry of record.messages ?? []) {
|
||||||
if (!entry || typeof entry !== "object") continue;
|
if (!entry || typeof entry !== "object") continue;
|
||||||
const message = entry as Record<string, unknown>;
|
const message = entry as Record<string, unknown>;
|
||||||
|
if (isCommandMessage(message)) {
|
||||||
|
const text = extractTextFromMessage(message);
|
||||||
|
if (text) chatLog.addSystem(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const text = extractTextFromMessage(message);
|
const text = extractTextFromMessage(message);
|
||||||
if (text) chatLog.addUser(text);
|
if (text) chatLog.addUser(text);
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
editor.setAutocompleteProvider(
|
editor.setAutocompleteProvider(
|
||||||
new CombinedAutocompleteProvider(
|
new CombinedAutocompleteProvider(
|
||||||
getSlashCommands({
|
getSlashCommands({
|
||||||
|
cfg: config,
|
||||||
provider: sessionInfo.modelProvider,
|
provider: sessionInfo.modelProvider,
|
||||||
model: sessionInfo.model,
|
model: sessionInfo.model,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+2
-2
@@ -9,9 +9,9 @@ import type { ClawdbotConfig } from "../src/config/config.js";
|
|||||||
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
|
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
|
||||||
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
|
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
|
||||||
import { createTestRegistry } from "../src/test-utils/channel-plugins.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());
|
afterAll(() => testEnv.cleanup());
|
||||||
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
|
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
|
|||||||
@@ -130,3 +130,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||||||
|
|
||||||
return { cleanup, tempHome };
|
return { cleanup, tempHome };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } {
|
||||||
|
return installTestEnv();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export type ExecApprovalsDefaults = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecApprovalsAllowlistEntry = {
|
export type ExecApprovalsAllowlistEntry = {
|
||||||
|
id?: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
lastUsedCommand?: string;
|
lastUsedCommand?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user