feat: multi-agent routing + multi-account providers

This commit is contained in:
Peter Steinberger 2026-01-06 18:25:37 +00:00
parent 50d4b17417
commit dbfa316d19
129 changed files with 3760 additions and 1126 deletions

View File

@ -187,7 +187,7 @@ actor BridgeServer {
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
channel: .last)) provider: .last))
case "agent.request": case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)" ?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel) let provider = GatewayAgentProvider(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message, message: message,
@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
channel: channel)) provider: provider))
default: default:
break break

View File

@ -79,14 +79,14 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
GatewayProcessManager.shared.setActive(true) GatewayProcessManager.shared.setActive(true)
} }
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text, message: text,
sessionKey: self.sessionKey, sessionKey: self.sessionKey,
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
channel: .last, provider: .last,
idempotencyKey: actionId)) idempotencyKey: actionId))
await MainActor.run { await MainActor.run {
guard let webView else { return } guard let webView else { return }

View File

@ -33,13 +33,13 @@ extension CronJobEditor {
case let .systemEvent(text): case let .systemEvent(text):
self.payloadKind = .systemEvent self.payloadKind = .systemEvent
self.systemEventText = text self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = message self.agentMessage = message
self.thinking = thinking ?? "" self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel) self.provider = GatewayAgentProvider(raw: provider)
self.to = to ?? "" self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false self.bestEffortDeliver = bestEffortDeliver ?? false
} }
@ -166,7 +166,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver payload["deliver"] = self.deliver
if self.deliver { if self.deliver {
payload["channel"] = self.channel.rawValue payload["provider"] = self.provider.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to } if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@ -13,7 +13,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic" self.agentMessage = "Run diagnostic"
self.deliver = true self.deliver = true
self.channel = .last self.provider = .last
self.to = "+15551230000" self.to = "+15551230000"
self.thinking = "low" self.thinking = "low"
self.timeoutSeconds = "90" self.timeoutSeconds = "90"

View File

@ -17,7 +17,7 @@ struct CronJobEditor: View {
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a surface, " "Isolated jobs always run an agent turn. The result can be delivered to a provider, "
+ "and a short summary is posted back to your main chat." + "and a short summary is posted back to your main chat."
static let mainPayloadNote = static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target." "System events are injected into the current main session. Agent turns require an isolated session target."
@ -42,7 +42,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = "" @State var systemEventText: String = ""
@State var agentMessage: String = "" @State var agentMessage: String = ""
@State var deliver: Bool = false @State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last @State var provider: GatewayAgentProvider = .last
@State var to: String = "" @State var to: String = ""
@State var thinking: String = "" @State var thinking: String = ""
@State var timeoutSeconds: String = "" @State var timeoutSeconds: String = ""
@ -309,7 +309,7 @@ struct CronJobEditor: View {
} }
GridRow { GridRow {
self.gridLabel("Deliver") self.gridLabel("Deliver")
Toggle("Deliver result to a surface", isOn: self.$deliver) Toggle("Deliver result to a provider", isOn: self.$deliver)
.toggleStyle(.switch) .toggleStyle(.switch)
} }
} }
@ -317,15 +317,15 @@ struct CronJobEditor: View {
if self.deliver { if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow { GridRow {
self.gridLabel("Channel") self.gridLabel("Provider")
Picker("", selection: self.$channel) { Picker("", selection: self.$provider) {
Text("last").tag(GatewayAgentChannel.last) Text("last").tag(GatewayAgentProvider.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp) Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram) Text("telegram").tag(GatewayAgentProvider.telegram)
Text("discord").tag(GatewayAgentChannel.discord) Text("discord").tag(GatewayAgentProvider.discord)
Text("slack").tag(GatewayAgentChannel.slack) Text("slack").tag(GatewayAgentProvider.slack)
Text("signal").tag(GatewayAgentChannel.signal) Text("signal").tag(GatewayAgentProvider.signal)
Text("imessage").tag(GatewayAgentChannel.imessage) Text("imessage").tag(GatewayAgentProvider.imessage)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)

View File

@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
channel: String?, provider: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
} }
var kind: String { var kind: String {
@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking), thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel), provider: container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to), to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default: default:
@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
switch self { switch self {
case let .systemEvent(text): case let .systemEvent(text):
try container.encode(text, forKey: .text) try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver) try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel) try container.encodeIfPresent(provider, forKey: .provider)
try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
} }

View File

@ -206,7 +206,7 @@ extension CronSettings {
Text(text) Text(text)
.font(.callout) .font(.callout)
.textSelection(.enabled) .textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _): case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(message) Text(message)
.font(.callout) .font(.callout)
@ -216,7 +216,7 @@ extension CronSettings {
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if deliver ?? false { if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary) StatusPill(text: "deliver", tint: .secondary)
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) } if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
} }
} }

View File

@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low", thinking: "low",
timeoutSeconds: 600, timeoutSeconds: 600,
deliver: true, deliver: true,
channel: "last", provider: "last",
to: nil, to: nil,
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),
@ -72,7 +72,7 @@ extension CronSettings {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),

View File

@ -59,7 +59,7 @@ final class DeepLinkHandler {
} }
do { do {
let channel = GatewayAgentChannel(raw: link.channel) let provider = GatewayAgentProvider(raw: link.channel)
let explicitSessionKey = link.sessionKey? let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty .nonEmpty
@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview, message: messagePreview,
sessionKey: resolvedSessionKey, sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver), deliver: provider.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
channel: channel, provider: provider,
timeoutSeconds: link.timeoutSeconds, timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString) idempotencyKey: UUID().uuidString)

View File

@ -5,7 +5,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection") private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
case last case last
case whatsapp case whatsapp
case telegram case telegram
@ -17,7 +17,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
init(raw: String?) { init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentChannel(rawValue: normalized) ?? .last self = GatewayAgentProvider(rawValue: normalized) ?? .last
} }
var isDeliverable: Bool { self != .webchat } var isDeliverable: Bool { self != .webchat }
@ -31,7 +31,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String? var thinking: String?
var deliver: Bool = false var deliver: Bool = false
var to: String? var to: String?
var channel: GatewayAgentChannel = .last var provider: GatewayAgentProvider = .last
var timeoutSeconds: Int? var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString var idempotencyKey: String = UUID().uuidString
} }
@ -368,7 +368,7 @@ extension GatewayConnection {
"thinking": AnyCodable(invocation.thinking ?? "default"), "thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver), "deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""), "to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue), "provider": AnyCodable(invocation.provider.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey), "idempotencyKey": AnyCodable(invocation.idempotencyKey),
] ]
if let timeout = invocation.timeoutSeconds { if let timeout = invocation.timeoutSeconds {
@ -389,7 +389,7 @@ extension GatewayConnection {
sessionKey: String, sessionKey: String,
deliver: Bool, deliver: Bool,
to: String?, to: String?,
channel: GatewayAgentChannel = .last, provider: GatewayAgentProvider = .last,
timeoutSeconds: Int? = nil, timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{ {
@ -399,7 +399,7 @@ extension GatewayConnection {
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
channel: channel, provider: provider,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }

View File

@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable {
struct GatewaySessionEntryRecord: Codable { struct GatewaySessionEntryRecord: Codable {
let key: String let key: String
let displayName: String? let displayName: String?
let surface: String? let provider: String?
let subject: String? let subject: String?
let room: String? let room: String?
let space: String? let space: String?
@ -71,7 +71,7 @@ struct SessionRow: Identifiable {
let key: String let key: String
let kind: SessionKind let kind: SessionKind
let displayName: String? let displayName: String?
let surface: String? let provider: String?
let subject: String? let subject: String?
let room: String? let room: String?
let space: String? let space: String?
@ -141,7 +141,7 @@ extension SessionRow {
key: "user@example.com", key: "user@example.com",
kind: .direct, kind: .direct,
displayName: nil, displayName: nil,
surface: nil, provider: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,
@ -158,7 +158,7 @@ extension SessionRow {
key: "discord:channel:release-squad", key: "discord:channel:release-squad",
kind: .group, kind: .group,
displayName: "discord:#release-squad", displayName: "discord:#release-squad",
surface: "discord", provider: "discord",
subject: nil, subject: nil,
room: "#release-squad", room: "#release-squad",
space: nil, space: nil,
@ -175,7 +175,7 @@ extension SessionRow {
key: "global", key: "global",
kind: .global, kind: .global,
displayName: nil, displayName: nil,
surface: nil, provider: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,
@ -298,7 +298,7 @@ enum SessionLoader {
key: entry.key, key: entry.key,
kind: SessionKind.from(key: entry.key), kind: SessionKind.from(key: entry.key),
displayName: entry.displayName, displayName: entry.displayName,
surface: entry.surface, provider: entry.provider,
subject: entry.subject, subject: entry.subject,
room: entry.room, room: entry.room,
space: entry.space, space: entry.space,

View File

@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low" var thinking: String = "low"
var deliver: Bool = true var deliver: Bool = true
var to: String? var to: String?
var channel: GatewayAgentChannel = .last var provider: GatewayAgentProvider = .last
} }
@discardableResult @discardableResult
@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError> options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{ {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.channel.shouldDeliver(options.deliver) let deliver = options.provider.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
channel: options.channel)) provider: options.provider))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")

55
src/agents/agent-scope.ts Normal file
View File

@ -0,0 +1,55 @@
import os from "node:os";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
export function resolveAgentIdFromSessionKey(
sessionKey?: string | null,
): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
}
export function resolveAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
): { workspace?: string; agentDir?: string } | undefined {
const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents;
if (!agents || typeof agents !== "object") return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
return {
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
};
}
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) return resolveUserPath(configured);
if (id === DEFAULT_AGENT_ID) {
const legacy = cfg.agent?.workspace?.trim();
if (legacy) return resolveUserPath(legacy);
return DEFAULT_AGENT_WORKSPACE_DIR;
}
return path.join(os.homedir(), `clawd-${id}`);
}
export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
if (configured) return resolveUserPath(configured);
const root = resolveStateDir(process.env, os.homedir);
return path.join(root, "agents", id, "agent");
}

View File

@ -49,14 +49,14 @@ export type AuthProfileStore = {
type LegacyAuthStore = Record<string, AuthProfileCredential>; type LegacyAuthStore = Record<string, AuthProfileCredential>;
function resolveAuthStorePath(): string { function resolveAuthStorePath(agentDir?: string): string {
const agentDir = resolveClawdbotAgentDir(); const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
return path.join(agentDir, AUTH_PROFILE_FILENAME); return path.join(resolved, AUTH_PROFILE_FILENAME);
} }
function resolveLegacyAuthStorePath(): string { function resolveLegacyAuthStorePath(agentDir?: string): string {
const agentDir = resolveClawdbotAgentDir(); const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
return path.join(agentDir, LEGACY_AUTH_FILENAME); return path.join(resolved, LEGACY_AUTH_FILENAME);
} }
function loadJsonFile(pathname: string): unknown { function loadJsonFile(pathname: string): unknown {
@ -104,8 +104,9 @@ function buildOAuthApiKey(
async function refreshOAuthTokenWithLock(params: { async function refreshOAuthTokenWithLock(params: {
profileId: string; profileId: string;
provider: OAuthProvider; provider: OAuthProvider;
agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const authPath = resolveAuthStorePath(); const authPath = resolveAuthStorePath(params.agentDir);
ensureAuthStoreFile(authPath); ensureAuthStoreFile(authPath);
let release: (() => Promise<void>) | undefined; let release: (() => Promise<void>) | undefined;
@ -121,7 +122,7 @@ async function refreshOAuthTokenWithLock(params: {
stale: 30_000, stale: 30_000,
}); });
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore(params.agentDir);
const cred = store.profiles[params.profileId]; const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") return null; if (!cred || cred.type !== "oauth") return null;
@ -142,7 +143,7 @@ async function refreshOAuthTokenWithLock(params: {
...result.newCredentials, ...result.newCredentials,
type: "oauth", type: "oauth",
}; };
saveAuthProfileStore(store); saveAuthProfileStore(store, params.agentDir);
return result; return result;
} finally { } finally {
if (release) { if (release) {
@ -261,13 +262,13 @@ export function loadAuthProfileStore(): AuthProfileStore {
return { version: AUTH_STORE_VERSION, profiles: {} }; return { version: AUTH_STORE_VERSION, profiles: {} };
} }
export function ensureAuthProfileStore(): AuthProfileStore { export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
const authPath = resolveAuthStorePath(); const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath); const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw); const asStore = coerceAuthStore(raw);
if (asStore) return asStore; if (asStore) return asStore;
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw); const legacy = coerceLegacyStore(legacyRaw);
const store: AuthProfileStore = { const store: AuthProfileStore = {
version: AUTH_STORE_VERSION, version: AUTH_STORE_VERSION,
@ -307,8 +308,11 @@ export function ensureAuthProfileStore(): AuthProfileStore {
return store; return store;
} }
export function saveAuthProfileStore(store: AuthProfileStore): void { export function saveAuthProfileStore(
const authPath = resolveAuthStorePath(); store: AuthProfileStore,
agentDir?: string,
): void {
const authPath = resolveAuthStorePath(agentDir);
const payload = { const payload = {
version: AUTH_STORE_VERSION, version: AUTH_STORE_VERSION,
profiles: store.profiles, profiles: store.profiles,
@ -321,10 +325,11 @@ export function saveAuthProfileStore(store: AuthProfileStore): void {
export function upsertAuthProfile(params: { export function upsertAuthProfile(params: {
profileId: string; profileId: string;
credential: AuthProfileCredential; credential: AuthProfileCredential;
agentDir?: string;
}): void { }): void {
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore(params.agentDir);
store.profiles[params.profileId] = params.credential; store.profiles[params.profileId] = params.credential;
saveAuthProfileStore(store); saveAuthProfileStore(store, params.agentDir);
} }
export function listProfilesForProvider( export function listProfilesForProvider(
@ -354,8 +359,9 @@ export function isProfileInCooldown(
export function markAuthProfileUsed(params: { export function markAuthProfileUsed(params: {
store: AuthProfileStore; store: AuthProfileStore;
profileId: string; profileId: string;
agentDir?: string;
}): void { }): void {
const { store, profileId } = params; const { store, profileId, agentDir } = params;
if (!store.profiles[profileId]) return; if (!store.profiles[profileId]) return;
store.usageStats = store.usageStats ?? {}; store.usageStats = store.usageStats ?? {};
@ -365,7 +371,7 @@ export function markAuthProfileUsed(params: {
errorCount: 0, errorCount: 0,
cooldownUntil: undefined, cooldownUntil: undefined,
}; };
saveAuthProfileStore(store); saveAuthProfileStore(store, agentDir);
} }
export function calculateAuthProfileCooldownMs(errorCount: number): number { export function calculateAuthProfileCooldownMs(errorCount: number): number {
@ -383,8 +389,9 @@ export function calculateAuthProfileCooldownMs(errorCount: number): number {
export function markAuthProfileCooldown(params: { export function markAuthProfileCooldown(params: {
store: AuthProfileStore; store: AuthProfileStore;
profileId: string; profileId: string;
agentDir?: string;
}): void { }): void {
const { store, profileId } = params; const { store, profileId, agentDir } = params;
if (!store.profiles[profileId]) return; if (!store.profiles[profileId]) return;
store.usageStats = store.usageStats ?? {}; store.usageStats = store.usageStats ?? {};
@ -399,7 +406,7 @@ export function markAuthProfileCooldown(params: {
errorCount, errorCount,
cooldownUntil: Date.now() + backoffMs, cooldownUntil: Date.now() + backoffMs,
}; };
saveAuthProfileStore(store); saveAuthProfileStore(store, agentDir);
} }
/** /**
@ -408,8 +415,9 @@ export function markAuthProfileCooldown(params: {
export function clearAuthProfileCooldown(params: { export function clearAuthProfileCooldown(params: {
store: AuthProfileStore; store: AuthProfileStore;
profileId: string; profileId: string;
agentDir?: string;
}): void { }): void {
const { store, profileId } = params; const { store, profileId, agentDir } = params;
if (!store.usageStats?.[profileId]) return; if (!store.usageStats?.[profileId]) return;
store.usageStats[profileId] = { store.usageStats[profileId] = {
@ -417,7 +425,7 @@ export function clearAuthProfileCooldown(params: {
errorCount: 0, errorCount: 0,
cooldownUntil: undefined, cooldownUntil: undefined,
}; };
saveAuthProfileStore(store); saveAuthProfileStore(store, agentDir);
} }
export function resolveAuthProfileOrder(params: { export function resolveAuthProfileOrder(params: {
@ -527,6 +535,7 @@ export async function resolveApiKeyForProfile(params: {
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
store: AuthProfileStore; store: AuthProfileStore;
profileId: string; profileId: string;
agentDir?: string;
}): Promise<{ apiKey: string; provider: string; email?: string } | null> { }): Promise<{ apiKey: string; provider: string; email?: string } | null> {
const { cfg, store, profileId } = params; const { cfg, store, profileId } = params;
const cred = store.profiles[profileId]; const cred = store.profiles[profileId];
@ -550,6 +559,7 @@ export async function resolveApiKeyForProfile(params: {
const result = await refreshOAuthTokenWithLock({ const result = await refreshOAuthTokenWithLock({
profileId, profileId,
provider: cred.provider, provider: cred.provider,
agentDir: params.agentDir,
}); });
if (!result) return null; if (!result) return null;
return { return {
@ -558,7 +568,7 @@ export async function resolveApiKeyForProfile(params: {
email: cred.email, email: cred.email,
}; };
} catch (error) { } catch (error) {
const refreshedStore = ensureAuthProfileStore(); const refreshedStore = ensureAuthProfileStore(params.agentDir);
const refreshed = refreshedStore.profiles[profileId]; const refreshed = refreshedStore.profiles[profileId];
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
return { return {
@ -579,12 +589,13 @@ export function markAuthProfileGood(params: {
store: AuthProfileStore; store: AuthProfileStore;
provider: string; provider: string;
profileId: string; profileId: string;
agentDir?: string;
}): void { }): void {
const { store, provider, profileId } = params; const { store, provider, profileId, agentDir } = params;
const profile = store.profiles[profileId]; const profile = store.profiles[profileId];
if (!profile || profile.provider !== provider) return; if (!profile || profile.provider !== provider) return;
store.lastGood = { ...store.lastGood, [provider]: profileId }; store.lastGood = { ...store.lastGood, [provider]: profileId };
saveAuthProfileStore(store); saveAuthProfileStore(store, agentDir);
} }
export function resolveAuthStorePathForDisplay(): string { export function resolveAuthStorePathForDisplay(): string {

View File

@ -36,14 +36,14 @@ describe("sessions tools", () => {
kind: "direct", kind: "direct",
sessionId: "s-main", sessionId: "s-main",
updatedAt: 10, updatedAt: 10,
lastChannel: "whatsapp", lastProvider: "whatsapp",
}, },
{ {
key: "discord:group:dev", key: "discord:group:dev",
kind: "group", kind: "group",
sessionId: "s-group", sessionId: "s-group",
updatedAt: 11, updatedAt: 11,
surface: "discord", provider: "discord",
displayName: "discord:g-dev", displayName: "discord:g-dev",
}, },
{ {
@ -196,7 +196,7 @@ describe("sessions tools", () => {
const tool = createClawdbotTools({ const tool = createClawdbotTools({
agentSessionKey: requesterKey, agentSessionKey: requesterKey,
agentSurface: "discord", agentProvider: "discord",
}).find((candidate) => candidate.name === "sessions_send"); }).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined(); expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
@ -340,7 +340,7 @@ describe("sessions tools", () => {
const tool = createClawdbotTools({ const tool = createClawdbotTools({
agentSessionKey: requesterKey, agentSessionKey: requesterKey,
agentSurface: "discord", agentProvider: "discord",
}).find((candidate) => candidate.name === "sessions_send"); }).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined(); expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");

View File

@ -22,7 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
describe("subagents", () => { describe("subagents", () => {
it("sessions_spawn announces back to the requester group surface", async () => { it("sessions_spawn announces back to the requester group provider", async () => {
callGatewayMock.mockReset(); callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = []; const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0; let agentCallCount = 0;
@ -83,7 +83,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({ const tool = createClawdbotTools({
agentSessionKey: "discord:group:req", agentSessionKey: "discord:group:req",
agentSurface: "discord", agentProvider: "discord",
}).find((candidate) => candidate.name === "sessions_spawn"); }).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool"); if (!tool) throw new Error("missing sessions_spawn tool");
@ -103,14 +103,14 @@ describe("subagents", () => {
| undefined; | undefined;
expect(first?.lane).toBe("subagent"); expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false); expect(first?.deliver).toBe(false);
expect(first?.sessionKey?.startsWith("subagent:")).toBe(true); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(sendParams).toMatchObject({ expect(sendParams).toMatchObject({
provider: "discord", provider: "discord",
to: "channel:req", to: "channel:req",
message: "announce now", message: "announce now",
}); });
expect(deletedKey?.startsWith("subagent:")).toBe(true); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
}); });
it("sessions_spawn resolves main announce target from sessions.list", async () => { it("sessions_spawn resolves main announce target from sessions.list", async () => {
@ -129,7 +129,7 @@ describe("subagents", () => {
sessions: [ sessions: [
{ {
key: "main", key: "main",
lastChannel: "whatsapp", lastProvider: "whatsapp",
lastTo: "+123", lastTo: "+123",
}, },
], ],
@ -182,7 +182,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({ const tool = createClawdbotTools({
agentSessionKey: "main", agentSessionKey: "main",
agentSurface: "whatsapp", agentProvider: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn"); }).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool"); if (!tool) throw new Error("missing sessions_spawn tool");

View File

@ -16,11 +16,15 @@ import { createSlackTool } from "./tools/slack-tool.js";
export function createClawdbotTools(options?: { export function createClawdbotTools(options?: {
browserControlUrl?: string; browserControlUrl?: string;
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentProvider?: string;
agentDir?: string;
sandboxed?: boolean; sandboxed?: boolean;
config?: ClawdbotConfig; config?: ClawdbotConfig;
}): AnyAgentTool[] { }): AnyAgentTool[] {
const imageTool = createImageTool({ config: options?.config }); const imageTool = createImageTool({
config: options?.config,
agentDir: options?.agentDir,
});
return [ return [
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }), createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
createCanvasTool(), createCanvasTool(),
@ -39,12 +43,12 @@ export function createClawdbotTools(options?: {
}), }),
createSessionsSendTool({ createSessionsSendTool({
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface, agentProvider: options?.agentProvider,
sandboxed: options?.sandboxed, sandboxed: options?.sandboxed,
}), }),
createSessionsSpawnTool({ createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface, agentProvider: options?.agentProvider,
sandboxed: options?.sandboxed, sandboxed: options?.sandboxed,
}), }),
...(imageTool ? [imageTool] : []), ...(imageTool ? [imageTool] : []),

View File

@ -31,15 +31,17 @@ export async function resolveApiKeyForProvider(params: {
profileId?: string; profileId?: string;
preferredProfile?: string; preferredProfile?: string;
store?: AuthProfileStore; store?: AuthProfileStore;
agentDir?: string;
}): Promise<{ apiKey: string; profileId?: string; source: string }> { }): Promise<{ apiKey: string; profileId?: string; source: string }> {
const { provider, cfg, profileId, preferredProfile } = params; const { provider, cfg, profileId, preferredProfile } = params;
const store = params.store ?? ensureAuthProfileStore(); const store = params.store ?? ensureAuthProfileStore(params.agentDir);
if (profileId) { if (profileId) {
const resolved = await resolveApiKeyForProfile({ const resolved = await resolveApiKeyForProfile({
cfg, cfg,
store, store,
profileId, profileId,
agentDir: params.agentDir,
}); });
if (!resolved) { if (!resolved) {
throw new Error(`No credentials found for profile "${profileId}".`); throw new Error(`No credentials found for profile "${profileId}".`);
@ -63,6 +65,7 @@ export async function resolveApiKeyForProvider(params: {
cfg, cfg,
store, store,
profileId: candidate, profileId: candidate,
agentDir: params.agentDir,
}); });
if (resolved) { if (resolved) {
return { return {
@ -146,6 +149,7 @@ export async function getApiKeyForModel(params: {
profileId?: string; profileId?: string;
preferredProfile?: string; preferredProfile?: string;
store?: AuthProfileStore; store?: AuthProfileStore;
agentDir?: string;
}): Promise<{ apiKey: string; profileId?: string; source: string }> { }): Promise<{ apiKey: string; profileId?: string; source: string }> {
return resolveApiKeyForProvider({ return resolveApiKeyForProvider({
provider: params.model.provider, provider: params.model.provider,
@ -153,5 +157,6 @@ export async function getApiKeyForModel(params: {
profileId: params.profileId, profileId: params.profileId,
preferredProfile: params.preferredProfile, preferredProfile: params.preferredProfile,
store: params.store, store: params.store,
agentDir: params.agentDir,
}); });
} }

View File

@ -2,10 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { import { resolveClawdbotAgentDir } from "./agent-paths.js";
ensureClawdbotAgentEnv,
resolveClawdbotAgentDir,
} from "./agent-paths.js";
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>; type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
@ -26,15 +23,21 @@ async function readJson(pathname: string): Promise<unknown> {
export async function ensureClawdbotModelsJson( export async function ensureClawdbotModelsJson(
config?: ClawdbotConfig, config?: ClawdbotConfig,
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> { ): Promise<{ agentDir: string; wrote: boolean }> {
const cfg = config ?? loadConfig(); const cfg = config ?? loadConfig();
const providers = cfg.models?.providers; const providers = cfg.models?.providers;
if (!providers || Object.keys(providers).length === 0) { if (!providers || Object.keys(providers).length === 0) {
return { agentDir: resolveClawdbotAgentDir(), wrote: false }; const agentDir = agentDirOverride?.trim()
? agentDirOverride.trim()
: resolveClawdbotAgentDir();
return { agentDir, wrote: false };
} }
const mode = cfg.models?.mode ?? DEFAULT_MODE; const mode = cfg.models?.mode ?? DEFAULT_MODE;
const agentDir = ensureClawdbotAgentEnv(); const agentDir = agentDirOverride?.trim()
? agentDirOverride.trim()
: resolveClawdbotAgentDir();
const targetPath = path.join(agentDir, "models.json"); const targetPath = path.join(agentDir, "models.json");
let mergedProviders = providers; let mergedProviders = providers;

View File

@ -335,9 +335,10 @@ function resolvePromptSkills(
export async function compactEmbeddedPiSession(params: { export async function compactEmbeddedPiSession(params: {
sessionId: string; sessionId: string;
sessionKey?: string; sessionKey?: string;
surface?: string; messageProvider?: string;
sessionFile: string; sessionFile: string;
workspaceDir: string; workspaceDir: string;
agentDir?: string;
config?: ClawdbotConfig; config?: ClawdbotConfig;
skillsSnapshot?: SkillSnapshot; skillsSnapshot?: SkillSnapshot;
provider?: string; provider?: string;
@ -366,7 +367,7 @@ export async function compactEmbeddedPiSession(params: {
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
await ensureClawdbotModelsJson(params.config); await ensureClawdbotModelsJson(params.config);
const agentDir = resolveClawdbotAgentDir(); const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const { model, error, authStorage, modelRegistry } = resolveModel( const { model, error, authStorage, modelRegistry } = resolveModel(
provider, provider,
modelId, modelId,
@ -440,8 +441,9 @@ export async function compactEmbeddedPiSession(params: {
elevated: params.bashElevated, elevated: params.bashElevated,
}, },
sandbox, sandbox,
surface: params.surface, messageProvider: params.messageProvider,
sessionKey: params.sessionKey ?? params.sessionId, sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
config: params.config, config: params.config,
}); });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
@ -544,9 +546,10 @@ export async function compactEmbeddedPiSession(params: {
export async function runEmbeddedPiAgent(params: { export async function runEmbeddedPiAgent(params: {
sessionId: string; sessionId: string;
sessionKey?: string; sessionKey?: string;
surface?: string; messageProvider?: string;
sessionFile: string; sessionFile: string;
workspaceDir: string; workspaceDir: string;
agentDir?: string;
config?: ClawdbotConfig; config?: ClawdbotConfig;
skillsSnapshot?: SkillSnapshot; skillsSnapshot?: SkillSnapshot;
prompt: string; prompt: string;
@ -601,7 +604,7 @@ export async function runEmbeddedPiAgent(params: {
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
await ensureClawdbotModelsJson(params.config); await ensureClawdbotModelsJson(params.config);
const agentDir = resolveClawdbotAgentDir(); const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const { model, error, authStorage, modelRegistry } = resolveModel( const { model, error, authStorage, modelRegistry } = resolveModel(
provider, provider,
modelId, modelId,
@ -610,7 +613,7 @@ export async function runEmbeddedPiAgent(params: {
if (!model) { if (!model) {
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
} }
const authStore = ensureAuthProfileStore(); const authStore = ensureAuthProfileStore(agentDir);
const explicitProfileId = params.authProfileId?.trim(); const explicitProfileId = params.authProfileId?.trim();
const profileOrder = resolveAuthProfileOrder({ const profileOrder = resolveAuthProfileOrder({
cfg: params.config, cfg: params.config,
@ -678,7 +681,7 @@ export async function runEmbeddedPiAgent(params: {
attemptedThinking.add(thinkLevel); attemptedThinking.add(thinkLevel);
log.debug( log.debug(
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`, `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageProvider=${params.messageProvider ?? "unknown"}`,
); );
await fs.mkdir(resolvedWorkspace, { recursive: true }); await fs.mkdir(resolvedWorkspace, { recursive: true });
@ -734,8 +737,9 @@ export async function runEmbeddedPiAgent(params: {
elevated: params.bashElevated, elevated: params.bashElevated,
}, },
sandbox, sandbox,
surface: params.surface, messageProvider: params.messageProvider,
sessionKey: params.sessionKey ?? params.sessionId, sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
config: params.config, config: params.config,
}); });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();

View File

@ -100,24 +100,26 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it("scopes discord tool to discord surface", () => { it("scopes discord tool to discord provider", () => {
const other = createClawdbotCodingTools({ surface: "whatsapp" }); const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false); expect(other.some((tool) => tool.name === "discord")).toBe(false);
const discord = createClawdbotCodingTools({ surface: "discord" }); const discord = createClawdbotCodingTools({ messageProvider: "discord" });
expect(discord.some((tool) => tool.name === "discord")).toBe(true); expect(discord.some((tool) => tool.name === "discord")).toBe(true);
}); });
it("scopes slack tool to slack surface", () => { it("scopes slack tool to slack provider", () => {
const other = createClawdbotCodingTools({ surface: "whatsapp" }); const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "slack")).toBe(false); expect(other.some((tool) => tool.name === "slack")).toBe(false);
const slack = createClawdbotCodingTools({ surface: "slack" }); const slack = createClawdbotCodingTools({ messageProvider: "slack" });
expect(slack.some((tool) => tool.name === "slack")).toBe(true); expect(slack.some((tool) => tool.name === "slack")).toBe(true);
}); });
it("filters session tools for sub-agent sessions by default", () => { it("filters session tools for sub-agent sessions by default", () => {
const tools = createClawdbotCodingTools({ sessionKey: "subagent:test" }); const tools = createClawdbotCodingTools({
sessionKey: "agent:main:subagent:test",
});
const names = new Set(tools.map((tool) => tool.name)); const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_list")).toBe(false); expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false); expect(names.has("sessions_history")).toBe(false);
@ -131,7 +133,7 @@ describe("createClawdbotCodingTools", () => {
it("supports allow-only sub-agent tool policy", () => { it("supports allow-only sub-agent tool policy", () => {
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
sessionKey: "subagent:test", sessionKey: "agent:main:subagent:test",
// Intentionally partial config; only fields used by pi-tools are provided. // Intentionally partial config; only fields used by pi-tools are provided.
config: { config: {
agent: { agent: {

View File

@ -9,6 +9,7 @@ import {
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
import { import {
type BashToolDefaults, type BashToolDefaults,
@ -340,11 +341,6 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
"sessions_spawn", "sessions_spawn",
]; ];
function isSubagentSessionKey(sessionKey?: string): boolean {
const key = sessionKey?.trim().toLowerCase() ?? "";
return key.startsWith("subagent:");
}
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
const configured = cfg?.agent?.subagents?.tools; const configured = cfg?.agent?.subagents?.tools;
const deny = [ const deny = [
@ -488,28 +484,31 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
}; };
} }
function normalizeSurface(surface?: string): string | undefined { function normalizeMessageProvider(
const trimmed = surface?.trim().toLowerCase(); messageProvider?: string,
): string | undefined {
const trimmed = messageProvider?.trim().toLowerCase();
return trimmed ? trimmed : undefined; return trimmed ? trimmed : undefined;
} }
function shouldIncludeDiscordTool(surface?: string): boolean { function shouldIncludeDiscordTool(messageProvider?: string): boolean {
const normalized = normalizeSurface(surface); const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false; if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:"); return normalized === "discord" || normalized.startsWith("discord:");
} }
function shouldIncludeSlackTool(surface?: string): boolean { function shouldIncludeSlackTool(messageProvider?: string): boolean {
const normalized = normalizeSurface(surface); const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false; if (!normalized) return false;
return normalized === "slack" || normalized.startsWith("slack:"); return normalized === "slack" || normalized.startsWith("slack:");
} }
export function createClawdbotCodingTools(options?: { export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults; bash?: BashToolDefaults & ProcessToolDefaults;
surface?: string; messageProvider?: string;
sandbox?: SandboxContext | null; sandbox?: SandboxContext | null;
sessionKey?: string; sessionKey?: string;
agentDir?: string;
config?: ClawdbotConfig; config?: ClawdbotConfig;
}): AnyAgentTool[] { }): AnyAgentTool[] {
const bashToolName = "bash"; const bashToolName = "bash";
@ -555,13 +554,14 @@ export function createClawdbotCodingTools(options?: {
...createClawdbotTools({ ...createClawdbotTools({
browserControlUrl: sandbox?.browser?.controlUrl, browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey, agentSessionKey: options?.sessionKey,
agentSurface: options?.surface, agentProvider: options?.messageProvider,
agentDir: options?.agentDir,
sandboxed: !!sandbox, sandboxed: !!sandbox,
config: options?.config, config: options?.config,
}), }),
]; ];
const allowDiscord = shouldIncludeDiscordTool(options?.surface); const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
const allowSlack = shouldIncludeSlackTool(options?.surface); const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
const filtered = tools.filter((tool) => { const filtered = tools.filter((tool) => {
if (tool.name === "discord") return allowDiscord; if (tool.name === "discord") return allowDiscord;
if (tool.name === "slack") return allowSlack; if (tool.name === "slack") return allowSlack;

View File

@ -14,7 +14,6 @@ import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveUserPath } from "../../utils.js"; import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js"; import { loadWebMedia } from "../../web/media.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { getApiKeyForModel } from "../model-auth.js"; import { getApiKeyForModel } from "../model-auth.js";
import { runWithImageModelFallback } from "../model-fallback.js"; import { runWithImageModelFallback } from "../model-fallback.js";
import { ensureClawdbotModelsJson } from "../models-config.js"; import { ensureClawdbotModelsJson } from "../models-config.js";
@ -78,15 +77,15 @@ function buildImageContext(
async function runImagePrompt(params: { async function runImagePrompt(params: {
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
agentDir: string;
modelOverride?: string; modelOverride?: string;
prompt: string; prompt: string;
base64: string; base64: string;
mimeType: string; mimeType: string;
}): Promise<{ text: string; provider: string; model: string }> { }): Promise<{ text: string; provider: string; model: string }> {
const agentDir = resolveClawdbotAgentDir(); await ensureClawdbotModelsJson(params.cfg, params.agentDir);
await ensureClawdbotModelsJson(params.cfg); const authStorage = discoverAuthStorage(params.agentDir);
const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
const result = await runWithImageModelFallback({ const result = await runWithImageModelFallback({
cfg: params.cfg, cfg: params.cfg,
@ -104,6 +103,7 @@ async function runImagePrompt(params: {
const apiKeyInfo = await getApiKeyForModel({ const apiKeyInfo = await getApiKeyForModel({
model, model,
cfg: params.cfg, cfg: params.cfg,
agentDir: params.agentDir,
}); });
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
const context = buildImageContext( const context = buildImageContext(
@ -130,8 +130,13 @@ async function runImagePrompt(params: {
export function createImageTool(options?: { export function createImageTool(options?: {
config?: ClawdbotConfig; config?: ClawdbotConfig;
agentDir?: string;
}): AnyAgentTool | null { }): AnyAgentTool | null {
if (!ensureImageToolConfigured(options?.config)) return null; if (!ensureImageToolConfigured(options?.config)) return null;
const agentDir = options?.agentDir;
if (!agentDir?.trim()) {
throw new Error("createImageTool requires agentDir when enabled");
}
return { return {
label: "Image", label: "Image",
name: "image", name: "image",
@ -175,6 +180,7 @@ export function createImageTool(options?: {
const base64 = media.buffer.toString("base64"); const base64 = media.buffer.toString("base64");
const result = await runImagePrompt({ const result = await runImagePrompt({
cfg: options?.config, cfg: options?.config,
agentDir,
modelOverride, modelOverride,
prompt: promptRaw, prompt: promptRaw,
base64, base64,

View File

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
describe("resolveAnnounceTarget", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
it("derives non-WhatsApp announce targets from the session key", async () => {
const target = await resolveAnnounceTarget({
sessionKey: "agent:main:discord:group:dev",
displayKey: "agent:main:discord:group:dev",
});
expect(target).toEqual({ provider: "discord", to: "channel:dev" });
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("hydrates WhatsApp accountId from sessions.list when available", async () => {
callGatewayMock.mockResolvedValueOnce({
sessions: [
{
key: "agent:main:whatsapp:group:123@g.us",
lastProvider: "whatsapp",
lastTo: "123@g.us",
lastAccountId: "work",
},
],
});
const target = await resolveAnnounceTarget({
sessionKey: "agent:main:whatsapp:group:123@g.us",
displayKey: "agent:main:whatsapp:group:123@g.us",
});
expect(target).toEqual({
provider: "whatsapp",
to: "123@g.us",
accountId: "work",
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const first = callGatewayMock.mock.calls[0]?.[0] as
| { method?: string }
| undefined;
expect(first).toBeDefined();
expect(first?.method).toBe("sessions.list");
});
});

View File

@ -7,9 +7,12 @@ export async function resolveAnnounceTarget(params: {
displayKey: string; displayKey: string;
}): Promise<AnnounceTarget | null> { }): Promise<AnnounceTarget | null> {
const parsed = resolveAnnounceTargetFromKey(params.sessionKey); const parsed = resolveAnnounceTargetFromKey(params.sessionKey);
if (parsed) return parsed;
const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey); const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey);
if (parsedDisplay) return parsedDisplay; const fallback = parsed ?? parsedDisplay ?? null;
// Most providers can derive (provider,to) from the session key directly.
// WhatsApp is special: we may need lastAccountId from the session store.
if (fallback && fallback.provider !== "whatsapp") return fallback;
try { try {
const list = (await callGateway({ const list = (await callGateway({
@ -24,13 +27,17 @@ export async function resolveAnnounceTarget(params: {
const match = const match =
sessions.find((entry) => entry?.key === params.sessionKey) ?? sessions.find((entry) => entry?.key === params.sessionKey) ??
sessions.find((entry) => entry?.key === params.displayKey); sessions.find((entry) => entry?.key === params.displayKey);
const channel = const provider =
typeof match?.lastChannel === "string" ? match.lastChannel : undefined; typeof match?.lastProvider === "string" ? match.lastProvider : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined; const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
if (channel && to) return { channel, to }; const accountId =
typeof match?.lastAccountId === "string"
? match.lastAccountId
: undefined;
if (provider && to) return { provider, to, accountId };
} catch { } catch {
// ignore // ignore
} }
return null; return fallback;
} }

View File

@ -58,8 +58,8 @@ export function classifySessionKind(params: {
export function deriveProvider(params: { export function deriveProvider(params: {
key: string; key: string;
kind: SessionKind; kind: SessionKind;
surface?: string | null; provider?: string | null;
lastChannel?: string | null; lastProvider?: string | null;
}): string { }): string {
if ( if (
params.kind === "cron" || params.kind === "cron" ||
@ -67,10 +67,10 @@ export function deriveProvider(params: {
params.kind === "node" params.kind === "node"
) )
return "internal"; return "internal";
const surface = normalizeKey(params.surface ?? undefined); const provider = normalizeKey(params.provider ?? undefined);
if (surface) return surface; if (provider) return provider;
const lastChannel = normalizeKey(params.lastChannel ?? undefined); const lastProvider = normalizeKey(params.lastProvider ?? undefined);
if (lastChannel) return lastChannel; if (lastProvider) return lastProvider;
const parts = params.key.split(":").filter(Boolean); const parts = params.key.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts[0]; return parts[0];

View File

@ -2,6 +2,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js"; import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js"; import { jsonResult, readStringParam } from "./common.js";
import { import {
@ -78,7 +83,7 @@ export function createSessionsHistoryTool(opts?: {
opts?.sandboxed === true && opts?.sandboxed === true &&
visibility === "spawned" && visibility === "spawned" &&
requesterInternalKey && requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:"); !isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) { if (restrictToSpawned) {
const ok = await isSpawnedSessionAllowed({ const ok = await isSpawnedSessionAllowed({
requesterSessionKey: requesterInternalKey, requesterSessionKey: requesterInternalKey,
@ -91,6 +96,48 @@ export function createSessionsHistoryTool(opts?: {
}); });
} }
} }
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(
parseAgentSessionKey(resolvedKey)?.agentId,
);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history denied by routing.agentToAgent.allow.",
});
}
}
const limit = const limit =
typeof params.limit === "number" && Number.isFinite(params.limit) typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit)) ? Math.max(1, Math.floor(params.limit))

View File

@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
}) as never,
};
});
import { createSessionsListTool } from "./sessions-list-tool.js";
describe("sessions_list gating", () => {
beforeEach(() => {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({
path: "/tmp/sessions.json",
sessions: [
{ key: "agent:main:main", kind: "direct" },
{ key: "agent:other:main", kind: "direct" },
],
});
});
it("filters out other agents when routing.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({
count: 1,
sessions: [{ key: "agent:main:main" }],
});
});
});

View File

@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js"; import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringArrayParam } from "./common.js"; import { jsonResult, readStringArrayParam } from "./common.js";
import { import {
@ -31,8 +36,9 @@ type SessionListRow = {
systemSent?: boolean; systemSent?: boolean;
abortedLastRun?: boolean; abortedLastRun?: boolean;
sendPolicy?: string; sendPolicy?: string;
lastChannel?: string; lastProvider?: string;
lastTo?: string; lastTo?: string;
lastAccountId?: string;
transcriptPath?: string; transcriptPath?: string;
messages?: unknown[]; messages?: unknown[];
}; };
@ -76,7 +82,7 @@ export function createSessionsListTool(opts?: {
opts?.sandboxed === true && opts?.sandboxed === true &&
visibility === "spawned" && visibility === "spawned" &&
requesterInternalKey && requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:"); !isSubagentSessionKey(requesterInternalKey);
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
value.trim().toLowerCase(), value.trim().toLowerCase(),
@ -120,12 +126,43 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined; const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const rows: SessionListRow[] = []; const rows: SessionListRow[] = [];
for (const entry of sessions) { for (const entry of sessions) {
if (!entry || typeof entry !== "object") continue; if (!entry || typeof entry !== "object") continue;
const key = typeof entry.key === "string" ? entry.key : ""; const key = typeof entry.key === "string" ? entry.key : "";
if (!key) continue; if (!key) continue;
const entryAgentId = normalizeAgentId(
parseAgentSessionKey(key)?.agentId,
);
const crossAgent = entryAgentId !== requesterAgentId;
if (crossAgent) {
if (!a2aEnabled) continue;
if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId))
continue;
}
if (key === "unknown") continue; if (key === "unknown") continue;
if (key === "global" && alias !== "global") continue; if (key === "global" && alias !== "global") continue;
@ -140,15 +177,21 @@ export function createSessionsListTool(opts?: {
mainKey, mainKey,
}); });
const surface = const entryProvider =
typeof entry.surface === "string" ? entry.surface : undefined; typeof entry.provider === "string" ? entry.provider : undefined;
const lastChannel = const lastProvider =
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined; typeof entry.lastProvider === "string"
const provider = deriveProvider({ ? entry.lastProvider
: undefined;
const lastAccountId =
typeof entry.lastAccountId === "string"
? entry.lastAccountId
: undefined;
const derivedProvider = deriveProvider({
key, key,
kind, kind,
surface, provider: entryProvider,
lastChannel, lastProvider,
}); });
const sessionId = const sessionId =
@ -161,7 +204,7 @@ export function createSessionsListTool(opts?: {
const row: SessionListRow = { const row: SessionListRow = {
key: displayKey, key: displayKey,
kind, kind,
provider, provider: derivedProvider,
displayName: displayName:
typeof entry.displayName === "string" typeof entry.displayName === "string"
? entry.displayName ? entry.displayName
@ -196,8 +239,9 @@ export function createSessionsListTool(opts?: {
: undefined, : undefined,
sendPolicy: sendPolicy:
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined, typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastChannel, lastProvider,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined, lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
lastAccountId,
transcriptPath, transcriptPath,
}; };

View File

@ -6,33 +6,38 @@ const DEFAULT_PING_PONG_TURNS = 5;
const MAX_PING_PONG_TURNS = 5; const MAX_PING_PONG_TURNS = 5;
export type AnnounceTarget = { export type AnnounceTarget = {
channel: string; provider: string;
to: string; to: string;
accountId?: string;
}; };
export function resolveAnnounceTargetFromKey( export function resolveAnnounceTargetFromKey(
sessionKey: string, sessionKey: string,
): AnnounceTarget | null { ): AnnounceTarget | null {
const parts = sessionKey.split(":").filter(Boolean); const rawParts = sessionKey.split(":").filter(Boolean);
const parts =
rawParts.length >= 3 && rawParts[0] === "agent"
? rawParts.slice(2)
: rawParts;
if (parts.length < 3) return null; if (parts.length < 3) return null;
const [surface, kind, ...rest] = parts; const [providerRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null; if (kind !== "group" && kind !== "channel") return null;
const id = rest.join(":").trim(); const id = rest.join(":").trim();
if (!id) return null; if (!id) return null;
if (!surface) return null; if (!providerRaw) return null;
const channel = surface.toLowerCase(); const provider = providerRaw.toLowerCase();
if (channel === "discord") { if (provider === "discord") {
return { channel, to: `channel:${id}` }; return { provider, to: `channel:${id}` };
} }
if (channel === "signal") { if (provider === "signal") {
return { channel, to: `group:${id}` }; return { provider, to: `group:${id}` };
} }
return { channel, to: id }; return { provider, to: id };
} }
export function buildAgentToAgentMessageContext(params: { export function buildAgentToAgentMessageContext(params: {
requesterSessionKey?: string; requesterSessionKey?: string;
requesterSurface?: string; requesterProvider?: string;
targetSessionKey: string; targetSessionKey: string;
}) { }) {
const lines = [ const lines = [
@ -40,8 +45,8 @@ export function buildAgentToAgentMessageContext(params: {
params.requesterSessionKey params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.` ? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterProvider
? `Agent 1 (requester) surface: ${params.requesterSurface}.` ? `Agent 1 (requester) provider: ${params.requesterProvider}.`
: undefined, : undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
].filter(Boolean); ].filter(Boolean);
@ -50,9 +55,9 @@ export function buildAgentToAgentMessageContext(params: {
export function buildAgentToAgentReplyContext(params: { export function buildAgentToAgentReplyContext(params: {
requesterSessionKey?: string; requesterSessionKey?: string;
requesterSurface?: string; requesterProvider?: string;
targetSessionKey: string; targetSessionKey: string;
targetChannel?: string; targetProvider?: string;
currentRole: "requester" | "target"; currentRole: "requester" | "target";
turn: number; turn: number;
maxTurns: number; maxTurns: number;
@ -68,12 +73,12 @@ export function buildAgentToAgentReplyContext(params: {
params.requesterSessionKey params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.` ? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterProvider
? `Agent 1 (requester) surface: ${params.requesterSurface}.` ? `Agent 1 (requester) provider: ${params.requesterProvider}.`
: undefined, : undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel params.targetProvider
? `Agent 2 (target) surface: ${params.targetChannel}.` ? `Agent 2 (target) provider: ${params.targetProvider}.`
: undefined, : undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`, `If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean); ].filter(Boolean);
@ -82,9 +87,9 @@ export function buildAgentToAgentReplyContext(params: {
export function buildAgentToAgentAnnounceContext(params: { export function buildAgentToAgentAnnounceContext(params: {
requesterSessionKey?: string; requesterSessionKey?: string;
requesterSurface?: string; requesterProvider?: string;
targetSessionKey: string; targetSessionKey: string;
targetChannel?: string; targetProvider?: string;
originalMessage: string; originalMessage: string;
roundOneReply?: string; roundOneReply?: string;
latestReply?: string; latestReply?: string;
@ -94,12 +99,12 @@ export function buildAgentToAgentAnnounceContext(params: {
params.requesterSessionKey params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.` ? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterProvider
? `Agent 1 (requester) surface: ${params.requesterSurface}.` ? `Agent 1 (requester) provider: ${params.requesterProvider}.`
: undefined, : undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel params.targetProvider
? `Agent 2 (target) surface: ${params.targetChannel}.` ? `Agent 2 (target) provider: ${params.targetProvider}.`
: undefined, : undefined,
`Original request: ${params.originalMessage}`, `Original request: ${params.originalMessage}`,
params.roundOneReply params.roundOneReply
@ -109,7 +114,7 @@ export function buildAgentToAgentAnnounceContext(params: {
? `Latest reply: ${params.latestReply}` ? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).", : "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`, `If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.", "Any other reply will be posted to the target provider.",
"After this reply, the agent-to-agent conversation is over.", "After this reply, the agent-to-agent conversation is over.",
].filter(Boolean); ].filter(Boolean);
return lines.join("\n"); return lines.join("\n");

View File

@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
}) as never,
};
});
import { createSessionsSendTool } from "./sessions-send-tool.js";
describe("sessions_send gating", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",
});
const result = await tool.execute("call1", {
sessionKey: "agent:other:main",
message: "hi",
timeoutSeconds: 0,
});
expect(callGatewayMock).not.toHaveBeenCalled();
expect(result.details).toMatchObject({ status: "forbidden" });
});
});

View File

@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js"; import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js"; import { jsonResult, readStringParam } from "./common.js";
@ -32,7 +37,7 @@ const SessionsSendToolSchema = Type.Object({
export function createSessionsSendTool(opts?: { export function createSessionsSendTool(opts?: {
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentProvider?: string;
sandboxed?: boolean; sandboxed?: boolean;
}): AnyAgentTool { }): AnyAgentTool {
return { return {
@ -67,7 +72,7 @@ export function createSessionsSendTool(opts?: {
opts?.sandboxed === true && opts?.sandboxed === true &&
visibility === "spawned" && visibility === "spawned" &&
requesterInternalKey && requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:"); !isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) { if (restrictToSpawned) {
try { try {
const list = (await callGateway({ const list = (await callGateway({
@ -120,9 +125,55 @@ export function createSessionsSendTool(opts?: {
alias, alias,
mainKey, mainKey,
}); });
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(
parseAgentSessionKey(resolvedKey)?.agentId,
);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey,
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.",
sessionKey: displayKey,
});
}
}
const agentMessageContext = buildAgentToAgentMessageContext({ const agentMessageContext = buildAgentToAgentMessageContext({
requesterSessionKey: opts?.agentSessionKey, requesterSessionKey: opts?.agentSessionKey,
requesterSurface: opts?.agentSurface, requesterProvider: opts?.agentProvider,
targetSessionKey: displayKey, targetSessionKey: displayKey,
}); });
const sendParams = { const sendParams = {
@ -134,7 +185,7 @@ export function createSessionsSendTool(opts?: {
extraSystemPrompt: agentMessageContext, extraSystemPrompt: agentMessageContext,
}; };
const requesterSessionKey = opts?.agentSessionKey; const requesterSessionKey = opts?.agentSessionKey;
const requesterSurface = opts?.agentSurface; const requesterProvider = opts?.agentProvider;
const maxPingPongTurns = resolvePingPongTurns(cfg); const maxPingPongTurns = resolvePingPongTurns(cfg);
const runAgentToAgentFlow = async ( const runAgentToAgentFlow = async (
@ -166,7 +217,7 @@ export function createSessionsSendTool(opts?: {
sessionKey: resolvedKey, sessionKey: resolvedKey,
displayKey, displayKey,
}); });
const targetChannel = announceTarget?.channel ?? "unknown"; const targetProvider = announceTarget?.provider ?? "unknown";
if ( if (
maxPingPongTurns > 0 && maxPingPongTurns > 0 &&
requesterSessionKey && requesterSessionKey &&
@ -182,9 +233,9 @@ export function createSessionsSendTool(opts?: {
: "target"; : "target";
const replyPrompt = buildAgentToAgentReplyContext({ const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey, requesterSessionKey,
requesterSurface, requesterProvider,
targetSessionKey: displayKey, targetSessionKey: displayKey,
targetChannel, targetProvider,
currentRole, currentRole,
turn, turn,
maxTurns: maxPingPongTurns, maxTurns: maxPingPongTurns,
@ -208,9 +259,9 @@ export function createSessionsSendTool(opts?: {
} }
const announcePrompt = buildAgentToAgentAnnounceContext({ const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey, requesterSessionKey,
requesterSurface, requesterProvider,
targetSessionKey: displayKey, targetSessionKey: displayKey,
targetChannel, targetProvider,
originalMessage: message, originalMessage: message,
roundOneReply: primaryReply, roundOneReply: primaryReply,
latestReply, latestReply,
@ -233,7 +284,8 @@ export function createSessionsSendTool(opts?: {
params: { params: {
to: announceTarget.to, to: announceTarget.to,
message: announceReply.trim(), message: announceReply.trim(),
provider: announceTarget.channel, provider: announceTarget.provider,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}, },
timeoutMs: 10_000, timeoutMs: 10_000,

View File

@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js"; import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js"; import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js"; import { jsonResult, readStringParam } from "./common.js";
@ -26,7 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({
function buildSubagentSystemPrompt(params: { function buildSubagentSystemPrompt(params: {
requesterSessionKey?: string; requesterSessionKey?: string;
requesterSurface?: string; requesterProvider?: string;
childSessionKey: string; childSessionKey: string;
label?: string; label?: string;
}) { }) {
@ -36,8 +41,8 @@ function buildSubagentSystemPrompt(params: {
params.requesterSessionKey params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.` ? `Requester session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterProvider
? `Requester surface: ${params.requesterSurface}.` ? `Requester provider: ${params.requesterProvider}.`
: undefined, : undefined,
`Your session: ${params.childSessionKey}.`, `Your session: ${params.childSessionKey}.`,
"Run the task. Provide a clear final answer (plain text).", "Run the task. Provide a clear final answer (plain text).",
@ -48,7 +53,7 @@ function buildSubagentSystemPrompt(params: {
function buildSubagentAnnouncePrompt(params: { function buildSubagentAnnouncePrompt(params: {
requesterSessionKey?: string; requesterSessionKey?: string;
requesterSurface?: string; requesterProvider?: string;
announceChannel: string; announceChannel: string;
task: string; task: string;
subagentReply?: string; subagentReply?: string;
@ -58,16 +63,16 @@ function buildSubagentAnnouncePrompt(params: {
params.requesterSessionKey params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.` ? `Requester session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterProvider
? `Requester surface: ${params.requesterSurface}.` ? `Requester provider: ${params.requesterProvider}.`
: undefined, : undefined,
`Post target surface: ${params.announceChannel}.`, `Post target provider: ${params.announceChannel}.`,
`Original task: ${params.task}`, `Original task: ${params.task}`,
params.subagentReply params.subagentReply
? `Sub-agent result: ${params.subagentReply}` ? `Sub-agent result: ${params.subagentReply}`
: "Sub-agent result: (not available).", : "Sub-agent result: (not available).",
'Reply exactly "ANNOUNCE_SKIP" to stay silent.', 'Reply exactly "ANNOUNCE_SKIP" to stay silent.',
"Any other reply will be posted to the requester chat surface.", "Any other reply will be posted to the requester chat provider.",
].filter(Boolean); ].filter(Boolean);
return lines.join("\n"); return lines.join("\n");
} }
@ -76,7 +81,7 @@ async function runSubagentAnnounceFlow(params: {
childSessionKey: string; childSessionKey: string;
childRunId: string; childRunId: string;
requesterSessionKey: string; requesterSessionKey: string;
requesterSurface?: string; requesterProvider?: string;
requesterDisplayKey: string; requesterDisplayKey: string;
task: string; task: string;
timeoutMs: number; timeoutMs: number;
@ -109,8 +114,8 @@ async function runSubagentAnnounceFlow(params: {
const announcePrompt = buildSubagentAnnouncePrompt({ const announcePrompt = buildSubagentAnnouncePrompt({
requesterSessionKey: params.requesterSessionKey, requesterSessionKey: params.requesterSessionKey,
requesterSurface: params.requesterSurface, requesterProvider: params.requesterProvider,
announceChannel: announceTarget.channel, announceChannel: announceTarget.provider,
task: params.task, task: params.task,
subagentReply: reply, subagentReply: reply,
}); });
@ -135,7 +140,8 @@ async function runSubagentAnnounceFlow(params: {
params: { params: {
to: announceTarget.to, to: announceTarget.to,
message: announceReply.trim(), message: announceReply.trim(),
provider: announceTarget.channel, provider: announceTarget.provider,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}, },
timeoutMs: 10_000, timeoutMs: 10_000,
@ -159,7 +165,7 @@ async function runSubagentAnnounceFlow(params: {
export function createSessionsSpawnTool(opts?: { export function createSessionsSpawnTool(opts?: {
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentProvider?: string;
sandboxed?: boolean; sandboxed?: boolean;
}): AnyAgentTool { }): AnyAgentTool {
return { return {
@ -188,7 +194,7 @@ export function createSessionsSpawnTool(opts?: {
const requesterSessionKey = opts?.agentSessionKey; const requesterSessionKey = opts?.agentSessionKey;
if ( if (
typeof requesterSessionKey === "string" && typeof requesterSessionKey === "string" &&
requesterSessionKey.trim().toLowerCase().startsWith("subagent:") isSubagentSessionKey(requesterSessionKey)
) { ) {
return jsonResult({ return jsonResult({
status: "forbidden", status: "forbidden",
@ -208,7 +214,10 @@ export function createSessionsSpawnTool(opts?: {
mainKey, mainKey,
}); });
const childSessionKey = `subagent:${crypto.randomUUID()}`; const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) { if (opts?.sandboxed === true) {
try { try {
await callGateway({ await callGateway({
@ -222,7 +231,7 @@ export function createSessionsSpawnTool(opts?: {
} }
const childSystemPrompt = buildSubagentSystemPrompt({ const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey, requesterSessionKey,
requesterSurface: opts?.agentSurface, requesterProvider: opts?.agentProvider,
childSessionKey, childSessionKey,
label: label || undefined, label: label || undefined,
}); });
@ -265,7 +274,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey, childSessionKey,
childRunId, childRunId,
requesterSessionKey: requesterInternalKey, requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface, requesterProvider: opts?.agentProvider,
requesterDisplayKey, requesterDisplayKey,
task, task,
timeoutMs: 30_000, timeoutMs: 30_000,
@ -311,7 +320,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey, childSessionKey,
childRunId, childRunId,
requesterSessionKey: requesterInternalKey, requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface, requesterProvider: opts?.agentProvider,
requesterDisplayKey, requesterDisplayKey,
task, task,
timeoutMs: 30_000, timeoutMs: 30_000,
@ -329,7 +338,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey, childSessionKey,
childRunId, childRunId,
requesterSessionKey: requesterInternalKey, requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface, requesterProvider: opts?.agentProvider,
requesterDisplayKey, requesterDisplayKey,
task, task,
timeoutMs: 30_000, timeoutMs: 30_000,
@ -350,7 +359,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey, childSessionKey,
childRunId, childRunId,
requesterSessionKey: requesterInternalKey, requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface, requesterProvider: opts?.agentProvider,
requesterDisplayKey, requesterDisplayKey,
task, task,
timeoutMs: 30_000, timeoutMs: 30_000,

View File

@ -47,7 +47,7 @@ describe("chunkText", () => {
}); });
describe("resolveTextChunkLimit", () => { describe("resolveTextChunkLimit", () => {
it("uses per-surface defaults", () => { it("uses per-provider defaults", () => {
expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000); expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000); expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000); expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000);

View File

@ -4,7 +4,7 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
export type TextChunkSurface = export type TextChunkProvider =
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
| "discord" | "discord"
@ -13,7 +13,7 @@ export type TextChunkSurface =
| "imessage" | "imessage"
| "webchat"; | "webchat";
const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = { const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
whatsapp: 4000, whatsapp: 4000,
telegram: 4000, telegram: 4000,
discord: 2000, discord: 2000,
@ -25,22 +25,22 @@ const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
export function resolveTextChunkLimit( export function resolveTextChunkLimit(
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
surface?: TextChunkSurface, provider?: TextChunkProvider,
): number { ): number {
const surfaceOverride = (() => { const providerOverride = (() => {
if (!surface) return undefined; if (!provider) return undefined;
if (surface === "whatsapp") return cfg?.whatsapp?.textChunkLimit; if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
if (surface === "telegram") return cfg?.telegram?.textChunkLimit; if (provider === "telegram") return cfg?.telegram?.textChunkLimit;
if (surface === "discord") return cfg?.discord?.textChunkLimit; if (provider === "discord") return cfg?.discord?.textChunkLimit;
if (surface === "slack") return cfg?.slack?.textChunkLimit; if (provider === "slack") return cfg?.slack?.textChunkLimit;
if (surface === "signal") return cfg?.signal?.textChunkLimit; if (provider === "signal") return cfg?.signal?.textChunkLimit;
if (surface === "imessage") return cfg?.imessage?.textChunkLimit; if (provider === "imessage") return cfg?.imessage?.textChunkLimit;
return undefined; return undefined;
})(); })();
if (typeof surfaceOverride === "number" && surfaceOverride > 0) { if (typeof providerOverride === "number" && providerOverride > 0) {
return surfaceOverride; return providerOverride;
} }
if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface]; if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider];
return 4000; return 4000;
} }

View File

@ -3,7 +3,7 @@ import { normalizeE164 } from "../utils.js";
import type { MsgContext } from "./templating.js"; import type { MsgContext } from "./templating.js";
export type CommandAuthorization = { export type CommandAuthorization = {
isWhatsAppSurface: boolean; isWhatsAppProvider: boolean;
ownerList: string[]; ownerList: string[];
senderE164?: string; senderE164?: string;
isAuthorizedSender: boolean; isAuthorizedSender: boolean;
@ -17,7 +17,7 @@ export function resolveCommandAuthorization(params: {
commandAuthorized: boolean; commandAuthorized: boolean;
}): CommandAuthorization { }): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params; const { ctx, cfg, commandAuthorized } = params;
const surface = (ctx.Surface ?? "").trim().toLowerCase(); const provider = (ctx.Provider ?? "").trim().toLowerCase();
const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
const hasWhatsappPrefix = const hasWhatsappPrefix =
@ -26,30 +26,30 @@ export function resolveCommandAuthorization(params: {
const looksLikeE164 = (value: string) => const looksLikeE164 = (value: string) =>
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, ""))); Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
const inferWhatsApp = const inferWhatsApp =
!surface && !provider &&
Boolean(cfg.whatsapp?.allowFrom?.length) && Boolean(cfg.whatsapp?.allowFrom?.length) &&
(looksLikeE164(from) || looksLikeE164(to)); (looksLikeE164(from) || looksLikeE164(to));
const isWhatsAppSurface = const isWhatsAppProvider =
surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
const configuredAllowFrom = isWhatsAppSurface const configuredAllowFrom = isWhatsAppProvider
? cfg.whatsapp?.allowFrom ? cfg.whatsapp?.allowFrom
: undefined; : undefined;
const allowFromList = const allowFromList =
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
const allowAll = const allowAll =
!isWhatsAppSurface || !isWhatsAppProvider ||
allowFromList.length === 0 || allowFromList.length === 0 ||
allowFromList.some((entry) => entry.trim() === "*"); allowFromList.some((entry) => entry.trim() === "*");
const senderE164 = normalizeE164( const senderE164 = normalizeE164(
ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""), ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""),
); );
const ownerCandidates = const ownerCandidates =
isWhatsAppSurface && !allowAll isWhatsAppProvider && !allowAll
? allowFromList.filter((entry) => entry !== "*") ? allowFromList.filter((entry) => entry !== "*")
: []; : [];
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) { if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) {
ownerCandidates.push(to); ownerCandidates.push(to);
} }
const ownerList = ownerCandidates const ownerList = ownerCandidates
@ -57,14 +57,14 @@ export function resolveCommandAuthorization(params: {
.filter((entry): entry is string => Boolean(entry)); .filter((entry): entry is string => Boolean(entry));
const isOwner = const isOwner =
!isWhatsAppSurface || !isWhatsAppProvider ||
allowAll || allowAll ||
ownerList.length === 0 || ownerList.length === 0 ||
(senderE164 ? ownerList.includes(senderE164) : false); (senderE164 ? ownerList.includes(senderE164) : false);
const isAuthorizedSender = commandAuthorized && isOwner; const isAuthorizedSender = commandAuthorized && isOwner;
return { return {
isWhatsAppSurface, isWhatsAppProvider,
ownerList, ownerList,
senderE164: senderE164 || undefined, senderE164: senderE164 || undefined,
isAuthorizedSender, isAuthorizedSender,

View File

@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest";
import { formatAgentEnvelope } from "./envelope.js"; import { formatAgentEnvelope } from "./envelope.js";
describe("formatAgentEnvelope", () => { describe("formatAgentEnvelope", () => {
it("includes surface, from, ip, host, and timestamp", () => { it("includes provider, from, ip, host, and timestamp", () => {
const originalTz = process.env.TZ; const originalTz = process.env.TZ;
process.env.TZ = "UTC"; process.env.TZ = "UTC";
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({ const body = formatAgentEnvelope({
surface: "WebChat", provider: "WebChat",
from: "user1", from: "user1",
host: "mac-mini", host: "mac-mini",
ip: "10.0.0.5", ip: "10.0.0.5",
@ -30,7 +30,7 @@ describe("formatAgentEnvelope", () => {
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({ const body = formatAgentEnvelope({
surface: "WebChat", provider: "WebChat",
timestamp: ts, timestamp: ts,
body: "hello", body: "hello",
}); });
@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => {
}); });
it("handles missing optional fields", () => { it("handles missing optional fields", () => {
const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" }); const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" });
expect(body).toBe("[Telegram] hi"); expect(body).toBe("[Telegram] hi");
}); });
}); });

View File

@ -1,5 +1,5 @@
export type AgentEnvelopeParams = { export type AgentEnvelopeParams = {
surface: string; provider: string;
from?: string; from?: string;
timestamp?: number | Date; timestamp?: number | Date;
host?: string; host?: string;
@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined {
} }
export function formatAgentEnvelope(params: AgentEnvelopeParams): string { export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const surface = params.surface?.trim() || "Surface"; const provider = params.provider?.trim() || "Provider";
const parts: string[] = [surface]; const parts: string[] = [provider];
if (params.from?.trim()) parts.push(params.from.trim()); if (params.from?.trim()) parts.push(params.from.trim());
if (params.host?.trim()) parts.push(params.host.trim()); if (params.host?.trim()) parts.push(params.host.trim());
if (params.ip?.trim()) parts.push(params.ip.trim()); if (params.ip?.trim()) parts.push(params.ip.trim());

View File

@ -78,7 +78,7 @@ describe("block streaming", () => {
From: "+1004", From: "+1004",
To: "+2000", To: "+2000",
MessageSid: "msg-123", MessageSid: "msg-123",
Surface: "discord", Provider: "discord",
}, },
{ {
onReplyStart, onReplyStart,
@ -124,7 +124,7 @@ describe("block streaming", () => {
From: "+1004", From: "+1004",
To: "+2000", To: "+2000",
MessageSid: "msg-124", MessageSid: "msg-124",
Surface: "discord", Provider: "discord",
}, },
{ {
onBlockReply, onBlockReply,

View File

@ -321,7 +321,7 @@ describe("directive parsing", () => {
Body: "/elevated maybe", Body: "/elevated maybe",
From: "+1222", From: "+1222",
To: "+1222", To: "+1222",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+1222", SenderE164: "+1222",
}, },
{}, {},
@ -709,7 +709,7 @@ describe("directive parsing", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to openai/gpt-4.1-mini"); expect(text).toContain("Model set to openai/gpt-4.1-mini");
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const entry = store.main; const entry = store["agent:main:main"];
expect(entry.modelOverride).toBe("gpt-4.1-mini"); expect(entry.modelOverride).toBe("gpt-4.1-mini");
expect(entry.providerOverride).toBe("openai"); expect(entry.providerOverride).toBe("openai");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@ -741,7 +741,7 @@ describe("directive parsing", () => {
expect(text).toContain("Model set to Opus"); expect(text).toContain("Model set to Opus");
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("anthropic/claude-opus-4-5");
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const entry = store.main; const entry = store["agent:main:main"];
expect(entry.modelOverride).toBe("claude-opus-4-5"); expect(entry.modelOverride).toBe("claude-opus-4-5");
expect(entry.providerOverride).toBe("anthropic"); expect(entry.providerOverride).toBe("anthropic");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@ -791,7 +791,7 @@ describe("directive parsing", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Auth profile set to anthropic:work"); expect(text).toContain("Auth profile set to anthropic:work");
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const entry = store.main; const entry = store["agent:main:main"];
expect(entry.authProfileOverride).toBe("anthropic:work"); expect(entry.authProfileOverride).toBe("anthropic:work");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
@ -932,7 +932,7 @@ describe("directive parsing", () => {
Body: "hello", Body: "hello",
From: "+1004", From: "+1004",
To: "+2000", To: "+2000",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+1004", SenderE164: "+1004",
}, },
{}, {},

View File

@ -82,7 +82,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
const onReplyStart = vi.fn(); const onReplyStart = vi.fn();
await getReplyFromConfig( await getReplyFromConfig(
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" }, { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
{ onReplyStart, isHeartbeat: false }, { onReplyStart, isHeartbeat: false },
makeCfg(home), makeCfg(home),
); );
@ -100,7 +100,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
const onReplyStart = vi.fn(); const onReplyStart = vi.fn();
await getReplyFromConfig( await getReplyFromConfig(
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" }, { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
{ onReplyStart, isHeartbeat: true }, { onReplyStart, isHeartbeat: true },
makeCfg(home), makeCfg(home),
); );

View File

@ -23,6 +23,8 @@ import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js"; import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js"; import { HEARTBEAT_TOKEN } from "./tokens.js";
const MAIN_SESSION_KEY = "agent:main:main";
const webMocks = vi.hoisted(() => ({ const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true), webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
@ -166,7 +168,7 @@ describe("trigger handling", () => {
Body: "/send off", Body: "/send off",
From: "+1000", From: "+1000",
To: "+2000", To: "+2000",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+1000", SenderE164: "+1000",
}, },
{}, {},
@ -180,7 +182,7 @@ describe("trigger handling", () => {
string, string,
{ sendPolicy?: string } { sendPolicy?: string }
>; >;
expect(store.main?.sendPolicy).toBe("deny"); expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny");
}); });
}); });
@ -205,7 +207,7 @@ describe("trigger handling", () => {
Body: "/elevated on", Body: "/elevated on",
From: "+1000", From: "+1000",
To: "+2000", To: "+2000",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+1000", SenderE164: "+1000",
}, },
{}, {},
@ -219,7 +221,7 @@ describe("trigger handling", () => {
string, string,
{ elevatedLevel?: string } { elevatedLevel?: string }
>; >;
expect(store.main?.elevatedLevel).toBe("on"); expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
}); });
}); });
@ -245,7 +247,7 @@ describe("trigger handling", () => {
Body: "/elevated on", Body: "/elevated on",
From: "+1000", From: "+1000",
To: "+2000", To: "+2000",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+1000", SenderE164: "+1000",
}, },
{}, {},
@ -259,7 +261,7 @@ describe("trigger handling", () => {
string, string,
{ elevatedLevel?: string } { elevatedLevel?: string }
>; >;
expect(store.main?.elevatedLevel).toBeUndefined(); expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined();
}); });
}); });
@ -284,7 +286,7 @@ describe("trigger handling", () => {
Body: "please /elevated on now", Body: "please /elevated on now",
From: "+2000", From: "+2000",
To: "+2000", To: "+2000",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+2000", SenderE164: "+2000",
}, },
{}, {},
@ -316,7 +318,7 @@ describe("trigger handling", () => {
Body: "/elevated on", Body: "/elevated on",
From: "discord:123", From: "discord:123",
To: "user:123", To: "user:123",
Surface: "discord", Provider: "discord",
SenderName: "Peter Steinberger", SenderName: "Peter Steinberger",
SenderUsername: "steipete", SenderUsername: "steipete",
SenderTag: "steipete", SenderTag: "steipete",
@ -332,7 +334,7 @@ describe("trigger handling", () => {
string, string,
{ elevatedLevel?: string } { elevatedLevel?: string }
>; >;
expect(store.main?.elevatedLevel).toBe("on"); expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
}); });
}); });
@ -359,7 +361,7 @@ describe("trigger handling", () => {
Body: "/elevated on", Body: "/elevated on",
From: "discord:123", From: "discord:123",
To: "user:123", To: "user:123",
Surface: "discord", Provider: "discord",
SenderName: "steipete", SenderName: "steipete",
}, },
{}, {},
@ -510,7 +512,7 @@ describe("trigger handling", () => {
From: "123@g.us", From: "123@g.us",
To: "+2000", To: "+2000",
ChatType: "group", ChatType: "group",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+2000", SenderE164: "+2000",
}, },
{}, {},
@ -521,7 +523,9 @@ describe("trigger handling", () => {
const store = JSON.parse( const store = JSON.parse(
await fs.readFile(cfg.session.store, "utf-8"), await fs.readFile(cfg.session.store, "utf-8"),
) as Record<string, { groupActivation?: string }>; ) as Record<string, { groupActivation?: string }>;
expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe(
"always",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -535,7 +539,7 @@ describe("trigger handling", () => {
From: "123@g.us", From: "123@g.us",
To: "+2000", To: "+2000",
ChatType: "group", ChatType: "group",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+999", SenderE164: "+999",
}, },
{}, {},
@ -563,7 +567,7 @@ describe("trigger handling", () => {
From: "123@g.us", From: "123@g.us",
To: "+2000", To: "+2000",
ChatType: "group", ChatType: "group",
Surface: "whatsapp", Provider: "whatsapp",
SenderE164: "+2000", SenderE164: "+2000",
GroupSubject: "Test Group", GroupSubject: "Test Group",
GroupMembers: "Alice (+1), Bob (+2)", GroupMembers: "Alice (+1), Bob (+2)",
@ -879,7 +883,7 @@ describe("trigger handling", () => {
From: "group:whatsapp:demo", From: "group:whatsapp:demo",
To: "+2000", To: "+2000",
ChatType: "group" as const, ChatType: "group" as const,
Surface: "whatsapp" as const, Provider: "whatsapp" as const,
MediaPath: mediaPath, MediaPath: mediaPath,
MediaType: "image/jpeg", MediaType: "image/jpeg",
MediaUrl: mediaPath, MediaUrl: mediaPath,
@ -942,7 +946,7 @@ describe("group intro prompts", () => {
ChatType: "group", ChatType: "group",
GroupSubject: "Release Squad", GroupSubject: "Release Squad",
GroupMembers: "Alice, Bob", GroupMembers: "Alice, Bob",
Surface: "discord", Provider: "discord",
}, },
{}, {},
makeCfg(home), makeCfg(home),
@ -975,7 +979,7 @@ describe("group intro prompts", () => {
To: "+1999", To: "+1999",
ChatType: "group", ChatType: "group",
GroupSubject: "Ops", GroupSubject: "Ops",
Surface: "whatsapp", Provider: "whatsapp",
}, },
{}, {},
makeCfg(home), makeCfg(home),
@ -1008,7 +1012,7 @@ describe("group intro prompts", () => {
To: "+1777", To: "+1777",
ChatType: "group", ChatType: "group",
GroupSubject: "Dev Chat", GroupSubject: "Dev Chat",
Surface: "telegram", Provider: "telegram",
}, },
{}, {},
makeCfg(home), makeCfg(home),

View File

@ -2,7 +2,11 @@ import crypto from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
resolveAgentDir,
resolveAgentIdFromSessionKey,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { resolveModelRefFromString } from "../agents/model-selection.js"; import { resolveModelRefFromString } from "../agents/model-selection.js";
import { import {
abortEmbeddedPiRun, abortEmbeddedPiRun,
@ -108,10 +112,10 @@ function stripSenderPrefix(value?: string) {
function resolveElevatedAllowList( function resolveElevatedAllowList(
allowFrom: AgentElevatedAllowFromConfig | undefined, allowFrom: AgentElevatedAllowFromConfig | undefined,
surface: string, provider: string,
discordFallback?: Array<string | number>, discordFallback?: Array<string | number>,
): Array<string | number> | undefined { ): Array<string | number> | undefined {
switch (surface) { switch (provider) {
case "whatsapp": case "whatsapp":
return allowFrom?.whatsapp; return allowFrom?.whatsapp;
case "telegram": case "telegram":
@ -135,14 +139,14 @@ function resolveElevatedAllowList(
} }
function isApprovedElevatedSender(params: { function isApprovedElevatedSender(params: {
surface: string; provider: string;
ctx: MsgContext; ctx: MsgContext;
allowFrom?: AgentElevatedAllowFromConfig; allowFrom?: AgentElevatedAllowFromConfig;
discordFallback?: Array<string | number>; discordFallback?: Array<string | number>;
}): boolean { }): boolean {
const rawAllow = resolveElevatedAllowList( const rawAllow = resolveElevatedAllowList(
params.allowFrom, params.allowFrom,
params.surface, params.provider,
params.discordFallback, params.discordFallback,
); );
if (!rawAllow || rawAllow.length === 0) return false; if (!rawAllow || rawAllow.length === 0) return false;
@ -216,12 +220,15 @@ export async function getReplyFromConfig(
} }
} }
const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const workspaceDirRaw =
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({ const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw, dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap, ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
}); });
const workspaceDir = workspace.dir; const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId);
const timeoutMs = resolveAgentTimeoutMs({ cfg }); const timeoutMs = resolveAgentTimeoutMs({ cfg });
const configuredTypingSeconds = const configuredTypingSeconds =
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
@ -289,20 +296,20 @@ export async function getReplyFromConfig(
sessionCtx.Body = parsedDirectives.cleaned; sessionCtx.Body = parsedDirectives.cleaned;
sessionCtx.BodyStripped = parsedDirectives.cleaned; sessionCtx.BodyStripped = parsedDirectives.cleaned;
const surfaceKey = const messageProviderKey =
sessionCtx.Surface?.trim().toLowerCase() ?? sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Surface?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ??
""; "";
const elevatedConfig = agentCfg?.elevated; const elevatedConfig = agentCfg?.elevated;
const discordElevatedFallback = const discordElevatedFallback =
surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false; const elevatedEnabled = elevatedConfig?.enabled !== false;
const elevatedAllowed = const elevatedAllowed =
elevatedEnabled && elevatedEnabled &&
Boolean( Boolean(
surfaceKey && messageProviderKey &&
isApprovedElevatedSender({ isApprovedElevatedSender({
surface: surfaceKey, provider: messageProviderKey,
ctx, ctx,
allowFrom: elevatedConfig?.allowFrom, allowFrom: elevatedConfig?.allowFrom,
discordFallback: discordElevatedFallback, discordFallback: discordElevatedFallback,
@ -345,7 +352,7 @@ export async function getReplyFromConfig(
: "text_end"; : "text_end";
const blockStreamingEnabled = resolvedBlockStreaming === "on"; const blockStreamingEnabled = resolvedBlockStreaming === "on";
const blockReplyChunking = blockStreamingEnabled const blockReplyChunking = blockStreamingEnabled
? resolveBlockStreamingChunking(cfg, sessionCtx.Surface) ? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
: undefined; : undefined;
const modelState = await createModelSelectionState({ const modelState = await createModelSelectionState({
@ -463,7 +470,7 @@ export async function getReplyFromConfig(
}); });
const isEmptyConfig = Object.keys(cfg).length === 0; const isEmptyConfig = Object.keys(cfg).length === 0;
if ( if (
command.isWhatsAppSurface && command.isWhatsAppProvider &&
isEmptyConfig && isEmptyConfig &&
command.from && command.from &&
command.to && command.to &&
@ -638,7 +645,7 @@ export async function getReplyFromConfig(
: queueBodyBase; : queueBodyBase;
const resolvedQueue = resolveQueueSettings({ const resolvedQueue = resolveQueueSettings({
cfg, cfg,
surface: sessionCtx.Surface, provider: sessionCtx.Provider,
sessionEntry, sessionEntry,
inlineMode: perMessageQueueMode, inlineMode: perMessageQueueMode,
inlineOptions: perMessageQueueOptions, inlineOptions: perMessageQueueOptions,
@ -669,9 +676,11 @@ export async function getReplyFromConfig(
summaryLine: baseBodyTrimmedRaw, summaryLine: baseBodyTrimmedRaw,
enqueuedAt: Date.now(), enqueuedAt: Date.now(),
run: { run: {
agentId,
agentDir,
sessionId: sessionIdFinal, sessionId: sessionIdFinal,
sessionKey, sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
sessionFile, sessionFile,
workspaceDir, workspaceDir,
config: cfg, config: cfg,

View File

@ -71,7 +71,7 @@ function createMinimalRun(params?: {
const typing = createTyping(); const typing = createTyping();
const opts = params?.opts; const opts = params?.opts;
const sessionCtx = { const sessionCtx = {
Surface: "whatsapp", Provider: "whatsapp",
MessageSid: "msg", MessageSid: "msg",
} as unknown as TemplateContext; } as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
@ -83,7 +83,7 @@ function createMinimalRun(params?: {
run: { run: {
sessionId: "session", sessionId: "session",
sessionKey, sessionKey,
surface: "whatsapp", messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl", sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp", workspaceDir: "/tmp",
config: {}, config: {},

View File

@ -186,9 +186,11 @@ export async function runReplyAgent(params: {
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId, sessionId: followupRun.run.sessionId,
sessionKey, sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, messageProvider:
sessionCtx.Provider?.trim().toLowerCase() || undefined,
sessionFile: followupRun.run.sessionFile, sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir, workspaceDir: followupRun.run.workspaceDir,
agentDir: followupRun.run.agentDir,
config: followupRun.run.config, config: followupRun.run.config,
skillsSnapshot: followupRun.run.skillsSnapshot, skillsSnapshot: followupRun.run.skillsSnapshot,
prompt: commandBody, prompt: commandBody,

View File

@ -1,10 +1,10 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js"; import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_MAX = 1200;
const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([ const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
"whatsapp", "whatsapp",
"telegram", "telegram",
"discord", "discord",
@ -14,24 +14,26 @@ const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
"webchat", "webchat",
]); ]);
function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined { function normalizeChunkProvider(
if (!surface) return undefined; provider?: string,
const cleaned = surface.trim().toLowerCase(); ): TextChunkProvider | undefined {
return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface) if (!provider) return undefined;
? (cleaned as TextChunkSurface) const cleaned = provider.trim().toLowerCase();
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
? (cleaned as TextChunkProvider)
: undefined; : undefined;
} }
export function resolveBlockStreamingChunking( export function resolveBlockStreamingChunking(
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
surface?: string, provider?: string,
): { ): {
minChars: number; minChars: number;
maxChars: number; maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence"; breakPreference: "paragraph" | "newline" | "sentence";
} { } {
const surfaceKey = normalizeChunkSurface(surface); const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, surfaceKey); const textLimit = resolveTextChunkLimit(cfg, providerKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk; const chunkCfg = cfg?.agent?.blockStreamingChunk;
const maxRequested = Math.max( const maxRequested = Math.max(
1, 1,

View File

@ -47,8 +47,8 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { incrementCompactionCount } from "./session-updates.js"; import { incrementCompactionCount } from "./session-updates.js";
export type CommandContext = { export type CommandContext = {
surface: string; provider: string;
isWhatsAppSurface: boolean; isWhatsAppProvider: boolean;
ownerList: string[]; ownerList: string[];
isAuthorizedSender: boolean; isAuthorizedSender: boolean;
senderE164?: string; senderE164?: string;
@ -123,7 +123,7 @@ export function buildCommandContext(params: {
cfg, cfg,
commandAuthorized: params.commandAuthorized, commandAuthorized: params.commandAuthorized,
}); });
const surface = (ctx.Surface ?? "").trim().toLowerCase(); const provider = (ctx.Provider ?? "").trim().toLowerCase();
const abortKey = const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized; const rawBodyNormalized = triggerBodyNormalized;
@ -132,8 +132,8 @@ export function buildCommandContext(params: {
: rawBodyNormalized; : rawBodyNormalized;
return { return {
surface, provider,
isWhatsAppSurface: auth.isWhatsAppSurface, isWhatsAppProvider: auth.isWhatsAppProvider,
ownerList: auth.ownerList, ownerList: auth.ownerList,
isAuthorizedSender: auth.isAuthorizedSender, isAuthorizedSender: auth.isAuthorizedSender,
senderE164: auth.senderE164, senderE164: auth.senderE164,
@ -220,14 +220,14 @@ export async function handleCommands(params: {
? normalizeE164(command.senderE164) ? normalizeE164(command.senderE164)
: ""; : "";
const isActivationOwner = const isActivationOwner =
!command.isWhatsAppSurface || activationOwnerList.length === 0 !command.isWhatsAppProvider || activationOwnerList.length === 0
? command.isAuthorizedSender ? command.isAuthorizedSender
: Boolean(activationSenderE164) && : Boolean(activationSenderE164) &&
activationOwnerList.includes(activationSenderE164); activationOwnerList.includes(activationSenderE164);
if ( if (
!command.isAuthorizedSender || !command.isAuthorizedSender ||
(command.isWhatsAppSurface && !isActivationOwner) (command.isWhatsAppProvider && !isActivationOwner)
) { ) {
logVerbose( logVerbose(
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`, `Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
@ -402,7 +402,7 @@ export async function handleCommands(params: {
const result = await compactEmbeddedPiSession({ const result = await compactEmbeddedPiSession({
sessionId, sessionId,
sessionKey, sessionKey,
surface: command.surface, messageProvider: command.provider,
sessionFile: resolveSessionTranscriptPath(sessionId), sessionFile: resolveSessionTranscriptPath(sessionId),
workspaceDir, workspaceDir,
config: cfg, config: cfg,
@ -469,7 +469,7 @@ export async function handleCommands(params: {
cfg, cfg,
entry: sessionEntry, entry: sessionEntry,
sessionKey, sessionKey,
surface: sessionEntry?.surface ?? command.surface, provider: sessionEntry?.provider ?? command.provider,
chatType: sessionEntry?.chatType, chatType: sessionEntry?.chatType,
}); });
if (sendPolicy === "deny") { if (sendPolicy === "deny") {

View File

@ -90,7 +90,7 @@ describe("createFollowupRunner compaction", () => {
run: { run: {
sessionId: "session", sessionId: "session",
sessionKey: "main", sessionKey: "main",
surface: "whatsapp", messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl", sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp", workspaceDir: "/tmp",
config: {}, config: {},

View File

@ -76,7 +76,7 @@ export function createFollowupRunner(params: {
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: queued.run.sessionId, sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey, sessionKey: queued.run.sessionKey,
surface: queued.run.surface, messageProvider: queued.run.messageProvider,
sessionFile: queued.run.sessionFile, sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir, workspaceDir: queued.run.workspaceDir,
config: queued.run.config, config: queued.run.config,

View File

@ -19,13 +19,13 @@ describe("resolveGroupRequireMention", () => {
}, },
}; };
const ctx: TemplateContext = { const ctx: TemplateContext = {
Surface: "discord", Provider: "discord",
From: "group:123", From: "group:123",
GroupRoom: "#general", GroupRoom: "#general",
GroupSpace: "145", GroupSpace: "145",
}; };
const groupResolution: GroupKeyResolution = { const groupResolution: GroupKeyResolution = {
surface: "discord", provider: "discord",
id: "123", id: "123",
chatType: "group", chatType: "group",
}; };
@ -44,12 +44,12 @@ describe("resolveGroupRequireMention", () => {
}, },
}; };
const ctx: TemplateContext = { const ctx: TemplateContext = {
Surface: "slack", Provider: "slack",
From: "slack:channel:C123", From: "slack:channel:C123",
GroupSubject: "#general", GroupSubject: "#general",
}; };
const groupResolution: GroupKeyResolution = { const groupResolution: GroupKeyResolution = {
surface: "slack", provider: "slack",
id: "C123", id: "C123",
chatType: "group", chatType: "group",
}; };

View File

@ -50,22 +50,23 @@ export function resolveGroupRequireMention(params: {
groupResolution?: GroupKeyResolution; groupResolution?: GroupKeyResolution;
}): boolean { }): boolean {
const { cfg, ctx, groupResolution } = params; const { cfg, ctx, groupResolution } = params;
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase(); const provider =
groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase();
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim(); const groupSpace = ctx.GroupSpace?.trim();
if ( if (
surface === "telegram" || provider === "telegram" ||
surface === "whatsapp" || provider === "whatsapp" ||
surface === "imessage" provider === "imessage"
) { ) {
return resolveProviderGroupRequireMention({ return resolveProviderGroupRequireMention({
cfg, cfg,
surface, provider,
groupId, groupId,
}); });
} }
if (surface === "discord") { if (provider === "discord") {
const guildEntry = resolveDiscordGuildEntry( const guildEntry = resolveDiscordGuildEntry(
cfg.discord?.guilds, cfg.discord?.guilds,
groupSpace, groupSpace,
@ -90,7 +91,7 @@ export function resolveGroupRequireMention(params: {
} }
return true; return true;
} }
if (surface === "slack") { if (provider === "slack") {
const channels = cfg.slack?.channels ?? {}; const channels = cfg.slack?.channels ?? {};
const keys = Object.keys(channels); const keys = Object.keys(channels);
if (keys.length === 0) return true; if (keys.length === 0) return true;
@ -137,18 +138,18 @@ export function buildGroupIntro(params: {
params.defaultActivation; params.defaultActivation;
const subject = params.sessionCtx.GroupSubject?.trim(); const subject = params.sessionCtx.GroupSubject?.trim();
const members = params.sessionCtx.GroupMembers?.trim(); const members = params.sessionCtx.GroupMembers?.trim();
const surface = params.sessionCtx.Surface?.trim().toLowerCase(); const provider = params.sessionCtx.Provider?.trim().toLowerCase();
const surfaceLabel = (() => { const providerLabel = (() => {
if (!surface) return "chat"; if (!provider) return "chat";
if (surface === "whatsapp") return "WhatsApp"; if (provider === "whatsapp") return "WhatsApp";
if (surface === "telegram") return "Telegram"; if (provider === "telegram") return "Telegram";
if (surface === "discord") return "Discord"; if (provider === "discord") return "Discord";
if (surface === "webchat") return "WebChat"; if (provider === "webchat") return "WebChat";
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`; return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`;
})(); })();
const subjectLine = subject const subjectLine = subject
? `You are replying inside the ${surfaceLabel} group "${subject}".` ? `You are replying inside the ${providerLabel} group "${subject}".`
: `You are replying inside a ${surfaceLabel} group chat.`; : `You are replying inside a ${providerLabel} group chat.`;
const membersLine = members ? `Group members: ${members}.` : undefined; const membersLine = members ? `Group members: ${members}.` : undefined;
const activationLine = const activationLine =
activation === "always" activation === "always"

View File

@ -23,9 +23,11 @@ export type FollowupRun = {
summaryLine?: string; summaryLine?: string;
enqueuedAt: number; enqueuedAt: number;
run: { run: {
agentId: string;
agentDir: string;
sessionId: string; sessionId: string;
sessionKey?: string; sessionKey?: string;
surface?: string; messageProvider?: string;
sessionFile: string; sessionFile: string;
workspaceDir: string; workspaceDir: string;
config: ClawdbotConfig; config: ClawdbotConfig;
@ -425,8 +427,8 @@ export function scheduleFollowupDrain(
} }
})(); })();
} }
function defaultQueueModeForSurface(surface?: string): QueueMode { function defaultQueueModeForProvider(provider?: string): QueueMode {
const normalized = surface?.trim().toLowerCase(); const normalized = provider?.trim().toLowerCase();
if (normalized === "discord") return "collect"; if (normalized === "discord") return "collect";
if (normalized === "webchat") return "collect"; if (normalized === "webchat") return "collect";
if (normalized === "whatsapp") return "collect"; if (normalized === "whatsapp") return "collect";
@ -437,23 +439,23 @@ function defaultQueueModeForSurface(surface?: string): QueueMode {
} }
export function resolveQueueSettings(params: { export function resolveQueueSettings(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
surface?: string; provider?: string;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
inlineMode?: QueueMode; inlineMode?: QueueMode;
inlineOptions?: Partial<QueueSettings>; inlineOptions?: Partial<QueueSettings>;
}): QueueSettings { }): QueueSettings {
const surfaceKey = params.surface?.trim().toLowerCase(); const providerKey = params.provider?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue; const queueCfg = params.cfg.routing?.queue;
const surfaceModeRaw = const providerModeRaw =
surfaceKey && queueCfg?.bySurface providerKey && queueCfg?.byProvider
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey] ? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
: undefined; : undefined;
const resolvedMode = const resolvedMode =
params.inlineMode ?? params.inlineMode ??
normalizeQueueMode(params.sessionEntry?.queueMode) ?? normalizeQueueMode(params.sessionEntry?.queueMode) ??
normalizeQueueMode(surfaceModeRaw) ?? normalizeQueueMode(providerModeRaw) ??
normalizeQueueMode(queueCfg?.mode) ?? normalizeQueueMode(queueCfg?.mode) ??
defaultQueueModeForSurface(surfaceKey); defaultQueueModeForProvider(providerKey);
const debounceRaw = const debounceRaw =
params.inlineOptions?.debounceMs ?? params.inlineOptions?.debounceMs ??
params.sessionEntry?.queueDebounceMs ?? params.sessionEntry?.queueDebounceMs ??

View File

@ -7,6 +7,7 @@ import {
DEFAULT_RESET_TRIGGERS, DEFAULT_RESET_TRIGGERS,
type GroupKeyResolution, type GroupKeyResolution,
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey,
resolveGroupSessionKey, resolveGroupSessionKey,
resolveSessionKey, resolveSessionKey,
resolveStorePath, resolveStorePath,
@ -43,6 +44,7 @@ export async function initSessionState(params: {
const { ctx, cfg, commandAuthorized } = params; const { ctx, cfg, commandAuthorized } = params;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const mainKey = sessionCfg?.mainKey ?? "main"; const mainKey = sessionCfg?.mainKey ?? "main";
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const resetTriggers = sessionCfg?.resetTriggers?.length const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers ? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS; : DEFAULT_RESET_TRIGGERS;
@ -51,12 +53,12 @@ export async function initSessionState(params: {
1, 1,
); );
const sessionScope = sessionCfg?.scope ?? "per-sender"; const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store); const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const sessionStore: Record<string, SessionEntry> = const sessionStore: Record<string, SessionEntry> =
loadSessionStore(storePath); loadSessionStore(storePath);
let sessionKey: string | undefined; let sessionKey: string | undefined;
let sessionEntry: SessionEntry | undefined; let sessionEntry: SessionEntry;
let sessionId: string | undefined; let sessionId: string | undefined;
let isNewSession = false; let isNewSession = false;
@ -154,30 +156,30 @@ export async function initSessionState(params: {
queueDrop: baseEntry?.queueDrop, queueDrop: baseEntry?.queueDrop,
displayName: baseEntry?.displayName, displayName: baseEntry?.displayName,
chatType: baseEntry?.chatType, chatType: baseEntry?.chatType,
surface: baseEntry?.surface, provider: baseEntry?.provider,
subject: baseEntry?.subject, subject: baseEntry?.subject,
room: baseEntry?.room, room: baseEntry?.room,
space: baseEntry?.space, space: baseEntry?.space,
}; };
if (groupResolution?.surface) { if (groupResolution?.provider) {
const surface = groupResolution.surface; const provider = groupResolution.provider;
const subject = ctx.GroupSubject?.trim(); const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim(); const space = ctx.GroupSpace?.trim();
const explicitRoom = ctx.GroupRoom?.trim(); const explicitRoom = ctx.GroupRoom?.trim();
const isRoomSurface = surface === "discord" || surface === "slack"; const isRoomProvider = provider === "discord" || provider === "slack";
const nextRoom = const nextRoom =
explicitRoom ?? explicitRoom ??
(isRoomSurface && subject && subject.startsWith("#") (isRoomProvider && subject && subject.startsWith("#")
? subject ? subject
: undefined); : undefined);
const nextSubject = nextRoom ? undefined : subject; const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group"; sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.surface = surface; sessionEntry.provider = provider;
if (nextSubject) sessionEntry.subject = nextSubject; if (nextSubject) sessionEntry.subject = nextSubject;
if (nextRoom) sessionEntry.room = nextRoom; if (nextRoom) sessionEntry.room = nextRoom;
if (space) sessionEntry.space = space; if (space) sessionEntry.space = space;
sessionEntry.displayName = buildGroupDisplayName({ sessionEntry.displayName = buildGroupDisplayName({
surface: sessionEntry.surface, provider: sessionEntry.provider,
subject: sessionEntry.subject, subject: sessionEntry.subject,
room: sessionEntry.room, room: sessionEntry.room,
space: sessionEntry.space, space: sessionEntry.space,

View File

@ -24,7 +24,7 @@ describe("buildStatusMessage", () => {
verboseLevel: "on", verboseLevel: "on",
compactionCount: 2, compactionCount: 2,
}, },
sessionKey: "main", sessionKey: "agent:main:main",
sessionScope: "per-sender", sessionScope: "per-sender",
storePath: "/tmp/sessions.json", storePath: "/tmp/sessions.json",
resolvedThink: "medium", resolvedThink: "medium",
@ -39,7 +39,7 @@ describe("buildStatusMessage", () => {
expect(text).toContain("Agent: embedded pi"); expect(text).toContain("Agent: embedded pi");
expect(text).toContain("Runtime: direct"); expect(text).toContain("Runtime: direct");
expect(text).toContain("Context: 16k/32k (50%)"); expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("Session: main"); expect(text).toContain("Session: agent:main:main");
expect(text).toContain("compactions 2"); expect(text).toContain("compactions 2");
expect(text).toContain("Web: linked"); expect(text).toContain("Web: linked");
expect(text).toContain("heartbeat 45s"); expect(text).toContain("heartbeat 45s");
@ -70,7 +70,7 @@ describe("buildStatusMessage", () => {
groupActivation: "always", groupActivation: "always",
chatType: "group", chatType: "group",
}, },
sessionKey: "whatsapp:group:123@g.us", sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender", sessionScope: "per-sender",
webLinked: true, webLinked: true,
}); });
@ -91,6 +91,8 @@ describe("buildStatusMessage", () => {
const storePath = path.join( const storePath = path.join(
dir, dir,
".clawdbot", ".clawdbot",
"agents",
"main",
"sessions", "sessions",
"sessions.json", "sessions.json",
); );
@ -98,6 +100,8 @@ describe("buildStatusMessage", () => {
const logPath = path.join( const logPath = path.join(
dir, dir,
".clawdbot", ".clawdbot",
"agents",
"main",
"sessions", "sessions",
`${sessionId}.jsonl`, `${sessionId}.jsonl`,
); );
@ -135,7 +139,7 @@ describe("buildStatusMessage", () => {
totalTokens: 3, // would be wrong if cached prompt tokens exist totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000, contextTokens: 32_000,
}, },
sessionKey: "main", sessionKey: "agent:main:main",
sessionScope: "per-sender", sessionScope: "per-sender",
storePath, storePath,
webLinked: true, webLinked: true,

View File

@ -3,6 +3,8 @@ export type MsgContext = {
From?: string; From?: string;
To?: string; To?: string;
SessionKey?: string; SessionKey?: string;
/** Provider account id (multi-account). */
AccountId?: string;
MessageSid?: string; MessageSid?: string;
ReplyToId?: string; ReplyToId?: string;
ReplyToBody?: string; ReplyToBody?: string;
@ -24,7 +26,8 @@ export type MsgContext = {
SenderUsername?: string; SenderUsername?: string;
SenderTag?: string; SenderTag?: string;
SenderE164?: string; SenderE164?: string;
Surface?: string; /** Provider label (whatsapp|telegram|discord|imessage|...). */
Provider?: string;
WasMentioned?: boolean; WasMentioned?: boolean;
CommandAuthorized?: boolean; CommandAuthorized?: boolean;
}; };

View File

@ -154,8 +154,8 @@ export function registerCronCli(program: Command) {
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option( .option(
"--channel <channel>", "--provider <provider>",
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)",
"last", "last",
) )
.option( .option(
@ -255,7 +255,8 @@ export function registerCronCli(program: Command) {
? timeoutSeconds ? timeoutSeconds
: undefined, : undefined,
deliver: Boolean(opts.deliver), deliver: Boolean(opts.deliver),
channel: typeof opts.channel === "string" ? opts.channel : "last", provider:
typeof opts.provider === "string" ? opts.provider : "last",
to: to:
typeof opts.to === "string" && opts.to.trim() typeof opts.to === "string" && opts.to.trim()
? opts.to.trim() ? opts.to.trim()
@ -413,8 +414,8 @@ export function registerCronCli(program: Command) {
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option( .option(
"--channel <channel>", "--provider <provider>",
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)",
) )
.option( .option(
"--to <dest>", "--to <dest>",
@ -502,8 +503,8 @@ export function registerCronCli(program: Command) {
? timeoutSeconds ? timeoutSeconds
: undefined, : undefined,
deliver: Boolean(opts.deliver), deliver: Boolean(opts.deliver),
channel: provider:
typeof opts.channel === "string" ? opts.channel : undefined, typeof opts.provider === "string" ? opts.provider : undefined,
to: typeof opts.to === "string" ? opts.to : undefined, to: typeof opts.to === "string" ? opts.to : undefined,
bestEffortDeliver: Boolean(opts.bestEffortDeliver), bestEffortDeliver: Boolean(opts.bestEffortDeliver),
}; };

View File

@ -13,6 +13,7 @@ import { statusCommand } from "../commands/status.js";
import { updateCommand } from "../commands/update.js"; import { updateCommand } from "../commands/update.js";
import { import {
isNixMode, isNixMode,
loadConfig,
migrateLegacyConfig, migrateLegacyConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
writeConfigFile, writeConfigFile,
@ -21,6 +22,7 @@ import { danger, setVerbose } from "../globals.js";
import { loginWeb, logoutWeb } from "../provider-web.js"; import { loginWeb, logoutWeb } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import { registerBrowserCli } from "./browser-cli.js"; import { registerBrowserCli } from "./browser-cli.js";
import { registerCanvasCli } from "./canvas-cli.js"; import { registerCanvasCli } from "./canvas-cli.js";
import { registerCronCli } from "./cron-cli.js"; import { registerCronCli } from "./cron-cli.js";
@ -324,11 +326,18 @@ export function buildProgram() {
.description("Link your personal WhatsApp via QR (web provider)") .description("Link your personal WhatsApp via QR (web provider)")
.option("--verbose", "Verbose connection logs", false) .option("--verbose", "Verbose connection logs", false)
.option("--provider <provider>", "Provider alias (default: whatsapp)") .option("--provider <provider>", "Provider alias (default: whatsapp)")
.option("--account <id>", "WhatsApp account id (accountId)")
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
try { try {
const provider = opts.provider ?? "whatsapp"; const provider = opts.provider ?? "whatsapp";
await loginWeb(Boolean(opts.verbose), provider); await loginWeb(
Boolean(opts.verbose),
provider,
undefined,
defaultRuntime,
opts.account as string | undefined,
);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -339,10 +348,20 @@ export function buildProgram() {
.command("logout") .command("logout")
.description("Clear cached WhatsApp Web credentials") .description("Clear cached WhatsApp Web credentials")
.option("--provider <provider>", "Provider alias (default: whatsapp)") .option("--provider <provider>", "Provider alias (default: whatsapp)")
.option("--account <id>", "WhatsApp account id (accountId)")
.action(async (opts) => { .action(async (opts) => {
try { try {
void opts.provider; // placeholder for future multi-provider; currently web only. void opts.provider; // placeholder for future multi-provider; currently web only.
await logoutWeb(defaultRuntime); const cfg = loadConfig();
const account = resolveWhatsAppAccount({
cfg,
accountId: opts.account as string | undefined,
});
await logoutWeb({
runtime: defaultRuntime,
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
});
} catch (err) { } catch (err) {
defaultRuntime.error(danger(`Logout failed: ${String(err)}`)); defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -372,6 +391,7 @@ export function buildProgram() {
"--provider <provider>", "--provider <provider>",
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
) )
.option("--account <id>", "WhatsApp account id (accountId)")
.option("--dry-run", "Print payload and skip sending", false) .option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false) .option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
@ -388,7 +408,14 @@ Examples:
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps(); const deps = createDefaultDeps();
try { try {
await sendCommand(opts, deps, defaultRuntime); await sendCommand(
{
...opts,
account: opts.account as string | undefined,
},
deps,
defaultRuntime,
);
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);

View File

@ -127,7 +127,7 @@ export async function agentViaGatewayCommand(
sessionId: opts.sessionId, sessionId: opts.sessionId,
}); });
const channel = normalizeProvider(opts.provider) ?? "whatsapp"; const provider = normalizeProvider(opts.provider) ?? "whatsapp";
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const response = await callGateway<GatewayAgentResponse>({ const response = await callGateway<GatewayAgentResponse>({
@ -139,7 +139,7 @@ export async function agentViaGatewayCommand(
sessionKey, sessionKey,
thinking: opts.thinking, thinking: opts.thinking,
deliver: Boolean(opts.deliver), deliver: Boolean(opts.deliver),
channel, provider,
timeout: timeoutSeconds, timeout: timeoutSeconds,
lane: opts.lane, lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt, extraSystemPrompt: opts.extraSystemPrompt,

View File

@ -59,7 +59,8 @@ type AgentCommandOpts = {
json?: boolean; json?: boolean;
timeout?: string; timeout?: string;
deliver?: boolean; deliver?: boolean;
surface?: string; /** Message provider context (webchat|voicewake|whatsapp|...). */
messageProvider?: string;
provider?: string; // delivery provider (whatsapp|telegram|...) provider?: string; // delivery provider (whatsapp|telegram|...)
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@ -231,7 +232,7 @@ export async function agentCommand(
cfg, cfg,
entry: sessionEntry, entry: sessionEntry,
sessionKey, sessionKey,
surface: sessionEntry?.surface, provider: sessionEntry?.provider,
chatType: sessionEntry?.chatType, chatType: sessionEntry?.chatType,
}); });
if (sendPolicy === "deny") { if (sendPolicy === "deny") {
@ -379,8 +380,8 @@ export async function agentCommand(
let fallbackProvider = provider; let fallbackProvider = provider;
let fallbackModel = model; let fallbackModel = model;
try { try {
const surface = const messageProvider =
opts.surface?.trim().toLowerCase() || opts.messageProvider?.trim().toLowerCase() ||
(() => { (() => {
const raw = opts.provider?.trim().toLowerCase(); const raw = opts.provider?.trim().toLowerCase();
if (!raw) return undefined; if (!raw) return undefined;
@ -394,7 +395,7 @@ export async function agentCommand(
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId, sessionId,
sessionKey, sessionKey,
surface, messageProvider,
sessionFile, sessionFile,
workspaceDir, workspaceDir,
config: cfg, config: cfg,

View File

@ -0,0 +1,180 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
let tempRoot: string | null = null;
async function makeTempRoot() {
const root = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "clawdbot-doctor-"),
);
tempRoot = root;
return root;
}
afterEach(async () => {
if (!tempRoot) return;
await fs.promises.rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
});
function writeJson5(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
}
describe("doctor legacy state migrations", () => {
it("migrates legacy sessions into agents/<id>/sessions", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const legacySessionsDir = path.join(root, "sessions");
fs.mkdirSync(legacySessionsDir, { recursive: true });
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
"+1555": { sessionId: "a", updatedAt: 10 },
"+1666": { sessionId: "b", updatedAt: 20 },
"slack:channel:C123": { sessionId: "c", updatedAt: 30 },
"group:abc": { sessionId: "d", updatedAt: 40 },
"subagent:xyz": { sessionId: "e", updatedAt: 50 },
});
fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8");
fs.writeFileSync(path.join(legacySessionsDir, "b.jsonl"), "b", "utf-8");
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
const result = await runLegacyStateMigrations({
detected,
now: () => 123,
});
expect(result.warnings).toEqual([]);
const targetDir = path.join(root, "agents", "main", "sessions");
expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "b.jsonl"))).toBe(true);
expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false);
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:main"]?.sessionId).toBe("b");
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
expect(store["group:abc"]?.sessionId).toBe("d");
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
});
it("migrates legacy agent dir with conflict fallback", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const legacyAgentDir = path.join(root, "agent");
fs.mkdirSync(legacyAgentDir, { recursive: true });
fs.writeFileSync(path.join(legacyAgentDir, "foo.txt"), "legacy", "utf-8");
fs.writeFileSync(path.join(legacyAgentDir, "baz.txt"), "legacy2", "utf-8");
const targetAgentDir = path.join(root, "agents", "main", "agent");
fs.mkdirSync(targetAgentDir, { recursive: true });
fs.writeFileSync(path.join(targetAgentDir, "foo.txt"), "new", "utf-8");
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe(
"legacy2",
);
const backupDir = path.join(root, "agents", "main", "agent.legacy-123");
expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true);
});
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const oauthDir = path.join(root, "credentials");
fs.mkdirSync(oauthDir, { recursive: true });
fs.writeFileSync(path.join(oauthDir, "oauth.json"), "{}", "utf-8");
fs.writeFileSync(path.join(oauthDir, "creds.json"), "{}", "utf-8");
fs.writeFileSync(path.join(oauthDir, "session-abc.json"), "{}", "utf-8");
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const target = path.join(oauthDir, "whatsapp", "default");
expect(fs.existsSync(path.join(target, "creds.json"))).toBe(true);
expect(fs.existsSync(path.join(target, "session-abc.json"))).toBe(true);
expect(fs.existsSync(path.join(oauthDir, "oauth.json"))).toBe(true);
expect(fs.existsSync(path.join(oauthDir, "creds.json"))).toBe(false);
});
it("no-ops when nothing detected", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
const result = await runLegacyStateMigrations({ detected });
expect(result.changes).toEqual([]);
});
it("routes legacy state to routing.defaultAgentId", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = { routing: { defaultAgentId: "alpha" } };
const legacySessionsDir = path.join(root, "sessions");
fs.mkdirSync(legacySessionsDir, { recursive: true });
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
"+1555": { sessionId: "a", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const targetDir = path.join(root, "agents", "alpha", "sessions");
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:alpha:main"]?.sessionId).toBe("a");
});
it("honors session.mainKey when seeding the direct-chat bucket", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = { session: { mainKey: "work" } };
const legacySessionsDir = path.join(root, "sessions");
fs.mkdirSync(legacySessionsDir, { recursive: true });
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
"+1555": { sessionId: "a", updatedAt: 10 },
"+1666": { sessionId: "b", updatedAt: 20 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const targetDir = path.join(root, "agents", "main", "sessions");
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:work"]?.sessionId).toBe("b");
expect(store["agent:main:main"]).toBeUndefined();
});
});

View File

@ -0,0 +1,456 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import { saveSessionStore } from "../config/sessions.js";
import {
buildAgentMainSessionKey,
DEFAULT_ACCOUNT_ID,
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
export type LegacyStateDetection = {
targetAgentId: string;
targetMainKey: string;
stateDir: string;
oauthDir: string;
sessions: {
legacyDir: string;
legacyStorePath: string;
targetDir: string;
targetStorePath: string;
hasLegacy: boolean;
};
agentDir: {
legacyDir: string;
targetDir: string;
hasLegacy: boolean;
};
whatsappAuth: {
legacyDir: string;
targetDir: string;
hasLegacy: boolean;
};
preview: string[];
};
type SessionEntryLike = { sessionId?: string; updatedAt?: number } & Record<
string,
unknown
>;
function safeReadDir(dir: string): fs.Dirent[] {
try {
return fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
function existsDir(dir: string): boolean {
try {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function fileExists(p: string): boolean {
try {
return fs.existsSync(p) && fs.statSync(p).isFile();
} catch {
return false;
}
}
function isLegacyWhatsAppAuthFile(name: string): boolean {
if (name === "creds.json" || name === "creds.json.bak") return true;
if (!name.endsWith(".json")) return false;
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
}
function readSessionStoreJson5(storePath: string): {
store: Record<string, SessionEntryLike>;
ok: boolean;
} {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return { store: parsed as Record<string, SessionEntryLike>, ok: true };
}
} catch {
// ignore
}
return { store: {}, ok: false };
}
function isSurfaceGroupKey(key: string): boolean {
return key.includes(":group:") || key.includes(":channel:");
}
function isLegacyGroupKey(key: string): boolean {
return key.startsWith("group:") || key.includes("@g.us");
}
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const raw = key.trim();
if (!raw) return raw;
if (raw.startsWith("agent:")) return raw;
if (raw.toLowerCase().startsWith("subagent:")) {
const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
}
if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
}
return raw;
}
function pickLatestLegacyDirectEntry(
store: Record<string, SessionEntryLike>,
): SessionEntryLike | null {
let best: SessionEntryLike | null = null;
let bestUpdated = -1;
for (const [key, entry] of Object.entries(store)) {
if (!entry || typeof entry !== "object") continue;
const normalized = key.trim();
if (!normalized) continue;
if (normalized === "global") continue;
if (normalized.startsWith("agent:")) continue;
if (normalized.toLowerCase().startsWith("subagent:")) continue;
if (isLegacyGroupKey(normalized) || isSurfaceGroupKey(normalized)) continue;
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
if (updatedAt > bestUpdated) {
bestUpdated = updatedAt;
best = entry;
}
}
return best;
}
function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : null;
if (!sessionId) return null;
const updatedAt =
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: Date.now();
return { ...(entry as unknown as SessionEntry), sessionId, updatedAt };
}
function emptyDirOrMissing(dir: string): boolean {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
}
function removeDirIfEmpty(dir: string) {
if (!existsDir(dir)) return;
if (!emptyDirOrMissing(dir)) return;
try {
fs.rmdirSync(dir);
} catch {
// ignore
}
}
export async function detectLegacyStateMigrations(params: {
cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
}): Promise<LegacyStateDetection> {
const env = params.env ?? process.env;
const homedir = params.homedir ?? os.homedir;
const stateDir = resolveStateDir(env, homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const targetAgentId = normalizeAgentId(
params.cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const rawMainKey = params.cfg.session?.mainKey;
const targetMainKey =
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
? rawMainKey.trim()
: DEFAULT_MAIN_KEY;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
const sessionsTargetDir = path.join(
stateDir,
"agents",
targetAgentId,
"sessions",
);
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
const hasLegacySessions =
fileExists(sessionsLegacyStorePath) ||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
const targetWhatsAppAuthDir = path.join(
oauthDir,
"whatsapp",
DEFAULT_ACCOUNT_ID,
);
const hasLegacyWhatsAppAuth =
fileExists(path.join(oauthDir, "creds.json")) &&
!fileExists(path.join(targetWhatsAppAuthDir, "creds.json"));
const preview: string[] = [];
if (hasLegacySessions) {
preview.push(`- Sessions: ${sessionsLegacyDir}${sessionsTargetDir}`);
}
if (hasLegacyAgentDir) {
preview.push(`- Agent dir: ${legacyAgentDir}${targetAgentDir}`);
}
if (hasLegacyWhatsAppAuth) {
preview.push(
`- WhatsApp auth: ${oauthDir}${targetWhatsAppAuthDir} (keep oauth.json)`,
);
}
return {
targetAgentId,
targetMainKey,
stateDir,
oauthDir,
sessions: {
legacyDir: sessionsLegacyDir,
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions,
},
agentDir: {
legacyDir: legacyAgentDir,
targetDir: targetAgentDir,
hasLegacy: hasLegacyAgentDir,
},
whatsappAuth: {
legacyDir: oauthDir,
targetDir: targetWhatsAppAuthDir,
hasLegacy: hasLegacyWhatsAppAuth,
},
preview,
};
}
async function migrateLegacySessions(
detected: LegacyStateDetection,
now: () => number,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.sessions.hasLegacy) return { changes, warnings };
ensureDir(detected.sessions.targetDir);
const legacyParsed = fileExists(detected.sessions.legacyStorePath)
? readSessionStoreJson5(detected.sessions.legacyStorePath)
: { store: {}, ok: true };
const targetParsed = fileExists(detected.sessions.targetStorePath)
? readSessionStoreJson5(detected.sessions.targetStorePath)
: { store: {}, ok: true };
const legacyStore = legacyParsed.store;
const targetStore = targetParsed.store;
const normalizedLegacy: Record<string, SessionEntryLike> = {};
for (const [key, entry] of Object.entries(legacyStore)) {
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
if (!nextKey) continue;
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
}
const merged: Record<string, SessionEntryLike> = {
...normalizedLegacy,
...targetStore,
};
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
});
if (!merged[mainKey]) {
const latest = pickLatestLegacyDirectEntry(legacyStore);
if (latest?.sessionId) {
merged[mainKey] = latest;
changes.push(`Migrated latest direct-chat session → ${mainKey}`);
}
}
if (!legacyParsed.ok) {
warnings.push(
`Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`,
);
}
if (
legacyParsed.ok &&
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
) {
const normalized: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(merged)) {
const normalizedEntry = normalizeSessionEntry(entry);
if (!normalizedEntry) continue;
normalized[key] = normalizedEntry;
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(
`Merged sessions store → ${detected.sessions.targetStorePath}`,
);
}
const entries = safeReadDir(detected.sessions.legacyDir);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === "sessions.json") continue;
const from = path.join(detected.sessions.legacyDir, entry.name);
const to = path.join(detected.sessions.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(
`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`,
);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
if (legacyParsed.ok) {
try {
if (fileExists(detected.sessions.legacyStorePath)) {
fs.rmSync(detected.sessions.legacyStorePath, { force: true });
}
} catch {
// ignore
}
}
removeDirIfEmpty(detected.sessions.legacyDir);
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) =>
e.isFile(),
);
if (legacyLeft.length > 0) {
const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`;
try {
fs.renameSync(detected.sessions.legacyDir, backupDir);
warnings.push(`Left legacy sessions at ${backupDir}`);
} catch {
// ignore
}
}
return { changes, warnings };
}
async function migrateLegacyAgentDir(
detected: LegacyStateDetection,
now: () => number,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.agentDir.hasLegacy) return { changes, warnings };
ensureDir(detected.agentDir.targetDir);
const entries = safeReadDir(detected.agentDir.legacyDir);
for (const entry of entries) {
const from = path.join(detected.agentDir.legacyDir, entry.name);
const to = path.join(detected.agentDir.targetDir, entry.name);
if (fs.existsSync(to)) continue;
try {
fs.renameSync(from, to);
changes.push(
`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`,
);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
removeDirIfEmpty(detected.agentDir.legacyDir);
if (!emptyDirOrMissing(detected.agentDir.legacyDir)) {
const backupDir = path.join(
detected.stateDir,
"agents",
detected.targetAgentId,
`agent.legacy-${now()}`,
);
try {
fs.renameSync(detected.agentDir.legacyDir, backupDir);
warnings.push(`Left legacy agent dir at ${backupDir}`);
} catch (err) {
warnings.push(`Failed relocating legacy agent dir: ${String(err)}`);
}
}
return { changes, warnings };
}
async function migrateLegacyWhatsAppAuth(
detected: LegacyStateDetection,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.whatsappAuth.hasLegacy) return { changes, warnings };
ensureDir(detected.whatsappAuth.targetDir);
const entries = safeReadDir(detected.whatsappAuth.legacyDir);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === "oauth.json") continue;
if (!isLegacyWhatsAppAuthFile(entry.name)) continue;
const from = path.join(detected.whatsappAuth.legacyDir, entry.name);
const to = path.join(detected.whatsappAuth.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
return { changes, warnings };
}
export async function runLegacyStateMigrations(params: {
detected: LegacyStateDetection;
now?: () => number;
}): Promise<{ changes: string[]; warnings: string[] }> {
const now = params.now ?? (() => Date.now());
const detected = params.detected;
const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now);
const whatsappAuth = await migrateLegacyWhatsAppAuth(detected);
return {
changes: [
...sessions.changes,
...agentDir.changes,
...whatsappAuth.changes,
],
warnings: [
...sessions.warnings,
...agentDir.warnings,
...whatsappAuth.warnings,
],
};
}

View File

@ -135,6 +135,37 @@ vi.mock("./onboard-helpers.js", () => ({
printWizardHeader: vi.fn(), printWizardHeader: vi.fn(),
})); }));
vi.mock("./doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
stateDir: "/tmp/state",
oauthDir: "/tmp/oauth",
sessions: {
legacyDir: "/tmp/state/sessions",
legacyStorePath: "/tmp/state/sessions/sessions.json",
targetDir: "/tmp/state/agents/main/sessions",
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: false,
},
agentDir: {
legacyDir: "/tmp/state/agent",
targetDir: "/tmp/state/agents/main/agent",
hasLegacy: false,
},
whatsappAuth: {
legacyDir: "/tmp/oauth",
targetDir: "/tmp/oauth/whatsapp/default",
hasLegacy: false,
},
preview: [],
}),
runLegacyStateMigrations: vi.fn().mockResolvedValue({
changes: [],
warnings: [],
}),
}));
describe("doctor", () => { describe("doctor", () => {
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => { it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
readConfigFileSnapshot.mockResolvedValue({ readConfigFileSnapshot.mockResolvedValue({

View File

@ -34,6 +34,10 @@ import { defaultRuntime } from "../runtime.js";
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
import { resolveTelegramToken } from "../telegram/token.js"; import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164, resolveUserPath, sleep } from "../utils.js"; import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import { healthCommand } from "./health.js"; import { healthCommand } from "./health.js";
import { import {
applyWizardMetadata, applyWizardMetadata,
@ -834,6 +838,29 @@ export async function doctorCommand(
cfg = normalized.config; cfg = normalized.config;
} }
const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate = guardCancel(
await confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
}),
runtime,
);
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
});
if (migrated.changes.length > 0) {
note(migrated.changes.join("\n"), "Doctor changes");
}
if (migrated.warnings.length > 0) {
note(migrated.warnings.join("\n"), "Doctor warnings");
}
}
}
cfg = await maybeRepairSandboxImages(cfg, runtime); cfg = await maybeRepairSandboxImages(cfg, runtime);
await maybeMigrateLegacyGatewayService(cfg, runtime); await maybeMigrateLegacyGatewayService(cfg, runtime);

View File

@ -6,6 +6,7 @@ import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
import { resolveTelegramToken } from "../telegram/token.js"; import { resolveTelegramToken } from "../telegram/token.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
@ -58,8 +59,9 @@ export async function getHealthSnapshot(
timeoutMs?: number, timeoutMs?: number,
): Promise<HealthSummary> { ): Promise<HealthSummary> {
const cfg = loadConfig(); const cfg = loadConfig();
const linked = await webAuthExists(); const account = resolveWhatsAppAccount({ cfg });
const authAgeMs = getWebAuthAgeMs(); const linked = await webAuthExists(account.authDir);
const authAgeMs = getWebAuthAgeMs(account.authDir);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const storePath = resolveStorePath(cfg.session?.store); const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
@ -128,7 +130,9 @@ export async function healthCommand(
: "Web: not linked (run clawdbot login)", : "Web: not linked (run clawdbot login)",
); );
if (summary.web.linked) { if (summary.web.linked) {
logWebSelfId(runtime, true); const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg });
logWebSelfId(account.authDir, runtime, true);
} }
if (summary.web.connect) { if (summary.web.connect) {
const base = summary.web.connect.ok const base = summary.web.connect.ok

View File

@ -5,7 +5,7 @@ import type { DmPolicy } from "../config/types.js";
import { loginWeb } from "../provider-web.js"; import { loginWeb } from "../provider-web.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { resolveWebAuthDir } from "../web/session.js"; import { WA_WEB_AUTH_DIR } from "../web/session.js";
import type { WizardPrompter } from "../wizard/prompts.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary } from "./onboard-helpers.js"; import { detectBinary } from "./onboard-helpers.js";
import type { ProviderChoice } from "./onboard-types.js"; import type { ProviderChoice } from "./onboard-types.js";
@ -29,7 +29,7 @@ async function pathExists(filePath: string): Promise<boolean> {
} }
async function detectWhatsAppLinked(): Promise<boolean> { async function detectWhatsAppLinked(): Promise<boolean> {
const credsPath = path.join(resolveWebAuthDir(), "creds.json"); const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
return await pathExists(credsPath); return await pathExists(credsPath);
} }
@ -550,7 +550,7 @@ export async function setupProviders(
await prompter.note( await prompter.note(
[ [
"Scan the QR with WhatsApp on your phone.", "Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${resolveWebAuthDir()}/ for future runs.`, `Credentials are stored under ${WA_WEB_AUTH_DIR}/ for future runs.`,
].join("\n"), ].join("\n"),
"WhatsApp linking", "WhatsApp linking",
); );

View File

@ -14,6 +14,7 @@ export async function sendCommand(
dryRun?: boolean; dryRun?: boolean;
media?: string; media?: string;
gifPlayback?: boolean; gifPlayback?: boolean;
account?: string;
}, },
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
@ -173,6 +174,7 @@ export async function sendCommand(
message: opts.message, message: opts.message,
mediaUrl: opts.media, mediaUrl: opts.media,
gifPlayback: opts.gifPlayback, gifPlayback: opts.gifPlayback,
accountId: opts.account,
provider, provider,
idempotencyKey: randomIdempotencyKey(), idempotencyKey: randomIdempotencyKey(),
}, },

View File

@ -16,6 +16,7 @@ import { info } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js"; import { buildProviderSummary } from "../infra/provider-summary.js";
import { peekSystemEvents } from "../infra/system-events.js"; import { peekSystemEvents } from "../infra/system-events.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
@ -60,8 +61,9 @@ export type StatusSummary = {
export async function getStatusSummary(): Promise<StatusSummary> { export async function getStatusSummary(): Promise<StatusSummary> {
const cfg = loadConfig(); const cfg = loadConfig();
const linked = await webAuthExists(); const account = resolveWhatsAppAccount({ cfg });
const authAgeMs = getWebAuthAgeMs(); const linked = await webAuthExists(account.authDir);
const authAgeMs = getWebAuthAgeMs(account.authDir);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const providerSummary = await buildProviderSummary(cfg); const providerSummary = await buildProviderSummary(cfg);
const queuedSystemEvents = peekSystemEvents(); const queuedSystemEvents = peekSystemEvents();
@ -230,7 +232,9 @@ export async function statusCommand(
`Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`, `Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`,
); );
if (summary.web.linked) { if (summary.web.linked) {
logWebSelfId(runtime, true); const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg });
logWebSelfId(account.authDir, runtime, true);
} }
runtime.log(info("System:")); runtime.log(info("System:"));
for (const line of summary.providerSummary) { for (const line of summary.providerSummary) {

View File

@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "./config.js"; import type { ClawdbotConfig } from "./config.js";
export type GroupPolicySurface = "whatsapp" | "telegram" | "imessage"; export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
export type ProviderGroupConfig = { export type ProviderGroupConfig = {
requireMention?: boolean; requireMention?: boolean;
@ -17,21 +17,21 @@ type ProviderGroups = Record<string, ProviderGroupConfig>;
function resolveProviderGroups( function resolveProviderGroups(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
surface: GroupPolicySurface, provider: GroupPolicyProvider,
): ProviderGroups | undefined { ): ProviderGroups | undefined {
if (surface === "whatsapp") return cfg.whatsapp?.groups; if (provider === "whatsapp") return cfg.whatsapp?.groups;
if (surface === "telegram") return cfg.telegram?.groups; if (provider === "telegram") return cfg.telegram?.groups;
if (surface === "imessage") return cfg.imessage?.groups; if (provider === "imessage") return cfg.imessage?.groups;
return undefined; return undefined;
} }
export function resolveProviderGroupPolicy(params: { export function resolveProviderGroupPolicy(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
surface: GroupPolicySurface; provider: GroupPolicyProvider;
groupId?: string | null; groupId?: string | null;
}): ProviderGroupPolicy { }): ProviderGroupPolicy {
const { cfg, surface } = params; const { cfg, provider } = params;
const groups = resolveProviderGroups(cfg, surface); const groups = resolveProviderGroups(cfg, provider);
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
const normalizedId = params.groupId?.trim(); const normalizedId = params.groupId?.trim();
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
@ -54,7 +54,7 @@ export function resolveProviderGroupPolicy(params: {
export function resolveProviderGroupRequireMention(params: { export function resolveProviderGroupRequireMention(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
surface: GroupPolicySurface; provider: GroupPolicyProvider;
groupId?: string | null; groupId?: string | null;
requireMentionOverride?: boolean; requireMentionOverride?: boolean;
overrideOrder?: "before-config" | "after-config"; overrideOrder?: "before-config" | "after-config";

View File

@ -33,30 +33,30 @@ describe("sessions", () => {
); );
}); });
it("prefixes group keys with surface when available", () => { it("prefixes group keys with provider when available", () => {
expect( expect(
deriveSessionKey("per-sender", { deriveSessionKey("per-sender", {
From: "12345-678@g.us", From: "12345-678@g.us",
ChatType: "group", ChatType: "group",
Surface: "whatsapp", Provider: "whatsapp",
}), }),
).toBe("whatsapp:group:12345-678@g.us"); ).toBe("whatsapp:group:12345-678@g.us");
}); });
it("keeps explicit surface when provided in group key", () => { it("keeps explicit provider when provided in group key", () => {
expect( expect(
resolveSessionKey( resolveSessionKey(
"per-sender", "per-sender",
{ From: "group:discord:12345", ChatType: "group" }, { From: "group:discord:12345", ChatType: "group" },
"main", "main",
), ),
).toBe("discord:group:12345"); ).toBe("agent:main:discord:group:12345");
}); });
it("builds discord display name with guild+channel slugs", () => { it("builds discord display name with guild+channel slugs", () => {
expect( expect(
buildGroupDisplayName({ buildGroupDisplayName({
surface: "discord", provider: "discord",
room: "#general", room: "#general",
space: "friends-of-clawd", space: "friends-of-clawd",
id: "123", id: "123",
@ -66,22 +66,24 @@ describe("sessions", () => {
}); });
it("collapses direct chats to main by default", () => { it("collapses direct chats to main by default", () => {
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main"); expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe(
"agent:main:main",
);
}); });
it("collapses direct chats to main even when sender missing", () => { it("collapses direct chats to main even when sender missing", () => {
expect(resolveSessionKey("per-sender", {})).toBe("main"); expect(resolveSessionKey("per-sender", {})).toBe("agent:main:main");
}); });
it("maps direct chats to main key when provided", () => { it("maps direct chats to main key when provided", () => {
expect( expect(
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"), resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
).toBe("main"); ).toBe("agent:main:main");
}); });
it("uses custom main key when provided", () => { it("uses custom main key when provided", () => {
expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe( expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe(
"primary", "agent:main:primary",
); );
}); });
@ -92,17 +94,18 @@ describe("sessions", () => {
it("leaves groups untouched even with main key", () => { it("leaves groups untouched even with main key", () => {
expect( expect(
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"), resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
).toBe("group:12345-678@g.us"); ).toBe("agent:main:group:12345-678@g.us");
}); });
it("updateLastRoute persists channel and target", async () => { it("updateLastRoute persists provider and target", async () => {
const mainSessionKey = "agent:main:main";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json"); const storePath = path.join(dir, "sessions.json");
await fs.writeFile( await fs.writeFile(
storePath, storePath,
JSON.stringify( JSON.stringify(
{ {
main: { [mainSessionKey]: {
sessionId: "sess-1", sessionId: "sess-1",
updatedAt: 123, updatedAt: 123,
systemSent: true, systemSent: true,
@ -117,16 +120,16 @@ describe("sessions", () => {
await updateLastRoute({ await updateLastRoute({
storePath, storePath,
sessionKey: "main", sessionKey: mainSessionKey,
channel: "telegram", provider: "telegram",
to: " 12345 ", to: " 12345 ",
}); });
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
expect(store.main?.sessionId).toBe("sess-1"); expect(store[mainSessionKey]?.sessionId).toBe("sess-1");
expect(store.main?.updatedAt).toBeGreaterThanOrEqual(123); expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123);
expect(store.main?.lastChannel).toBe("telegram"); expect(store[mainSessionKey]?.lastProvider).toBe("telegram");
expect(store.main?.lastTo).toBe("12345"); expect(store[mainSessionKey]?.lastTo).toBe("12345");
}); });
it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => { it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => {
@ -134,7 +137,7 @@ describe("sessions", () => {
{ CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv, { CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
() => "/home/ignored", () => "/home/ignored",
); );
expect(dir).toBe("/custom/state/sessions"); expect(dir).toBe("/custom/state/agents/main/sessions");
}); });
it("falls back to CLAWDIS_STATE_DIR for session transcripts dir", () => { it("falls back to CLAWDIS_STATE_DIR for session transcripts dir", () => {
@ -142,6 +145,6 @@ describe("sessions", () => {
{ CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv, { CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv,
() => "/home/ignored", () => "/home/ignored",
); );
expect(dir).toBe("/legacy/state/sessions"); expect(dir).toBe("/legacy/state/agents/main/sessions");
}); });
}); });

View File

@ -6,6 +6,13 @@ import path from "node:path";
import type { Skill } from "@mariozechner/pi-coding-agent"; import type { Skill } from "@mariozechner/pi-coding-agent";
import JSON5 from "json5"; import JSON5 from "json5";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { resolveStateDir } from "./paths.js"; import { resolveStateDir } from "./paths.js";
@ -59,11 +66,11 @@ export type SessionEntry = {
contextTokens?: number; contextTokens?: number;
compactionCount?: number; compactionCount?: number;
displayName?: string; displayName?: string;
surface?: string; provider?: string;
subject?: string; subject?: string;
room?: string; room?: string;
space?: string; space?: string;
lastChannel?: lastProvider?:
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
| "discord" | "discord"
@ -72,13 +79,14 @@ export type SessionEntry = {
| "imessage" | "imessage"
| "webchat"; | "webchat";
lastTo?: string; lastTo?: string;
lastAccountId?: string;
skillsSnapshot?: SessionSkillSnapshot; skillsSnapshot?: SessionSkillSnapshot;
}; };
export type GroupKeyResolution = { export type GroupKeyResolution = {
key: string; key: string;
legacyKey?: string; legacyKey?: string;
surface?: string; provider?: string;
id?: string; id?: string;
chatType?: SessionChatType; chatType?: SessionChatType;
}; };
@ -89,26 +97,45 @@ export type SessionSkillSnapshot = {
resolvedSkills?: Skill[]; resolvedSkills?: Skill[];
}; };
function resolveAgentSessionsDir(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const root = resolveStateDir(env, homedir);
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
return path.join(root, "agents", id, "sessions");
}
export function resolveSessionTranscriptsDir( export function resolveSessionTranscriptsDir(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir, homedir: () => string = os.homedir,
): string { ): string {
return path.join(resolveStateDir(env, homedir), "sessions"); return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
} }
export function resolveDefaultSessionStorePath(): string { export function resolveDefaultSessionStorePath(agentId?: string): string {
return path.join(resolveSessionTranscriptsDir(), "sessions.json"); return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
} }
export const DEFAULT_RESET_TRIGGER = "/new"; export const DEFAULT_RESET_TRIGGER = "/new";
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"]; export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
export const DEFAULT_IDLE_MINUTES = 60; export const DEFAULT_IDLE_MINUTES = 60;
export function resolveSessionTranscriptPath(sessionId: string): string { export function resolveSessionTranscriptPath(
return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`); sessionId: string,
agentId?: string,
): string {
return path.join(resolveAgentSessionsDir(agentId), `${sessionId}.jsonl`);
} }
export function resolveStorePath(store?: string) { export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
if (!store) return resolveDefaultSessionStorePath(); const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
if (!store) return resolveDefaultSessionStorePath(agentId);
if (store.includes("{agentId}")) {
return path.resolve(
store.replaceAll("{agentId}", agentId).replace("~", os.homedir()),
);
}
if (store.startsWith("~")) if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir())); return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store); return path.resolve(store);
@ -116,9 +143,32 @@ export function resolveStorePath(store?: string) {
export function resolveMainSessionKey(cfg?: { export function resolveMainSessionKey(cfg?: {
session?: { scope?: SessionScope; mainKey?: string }; session?: { scope?: SessionScope; mainKey?: string };
routing?: { defaultAgentId?: string };
}): string { }): string {
if (cfg?.session?.scope === "global") return "global"; if (cfg?.session?.scope === "global") return "global";
return "main"; const agentId = normalizeAgentId(
cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const mainKey =
(cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
return buildAgentMainSessionKey({ agentId, mainKey });
}
export function resolveAgentIdFromSessionKey(
sessionKey?: string | null,
): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
}
export function resolveAgentMainSessionKey(params: {
cfg?: { session?: { mainKey?: string } };
agentId: string;
}): string {
const mainKey =
(params.cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() ||
DEFAULT_MAIN_KEY;
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
} }
function normalizeGroupLabel(raw?: string) { function normalizeGroupLabel(raw?: string) {
@ -137,14 +187,14 @@ function shortenGroupId(value?: string) {
} }
export function buildGroupDisplayName(params: { export function buildGroupDisplayName(params: {
surface?: string; provider?: string;
subject?: string; subject?: string;
room?: string; room?: string;
space?: string; space?: string;
id?: string; id?: string;
key: string; key: string;
}) { }) {
const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim(); const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
const room = params.room?.trim(); const room = params.room?.trim();
const space = params.space?.trim(); const space = params.space?.trim();
const subject = params.subject?.trim(); const subject = params.subject?.trim();
@ -169,7 +219,7 @@ export function buildGroupDisplayName(params: {
) { ) {
token = `g-${token}`; token = `g-${token}`;
} }
return token ? `${surfaceKey}:${token}` : surfaceKey; return token ? `${providerKey}:${token}` : providerKey;
} }
export function resolveGroupSessionKey( export function resolveGroupSessionKey(
@ -186,13 +236,13 @@ export function resolveGroupSessionKey(
from.includes(":channel:"); from.includes(":channel:");
if (!isGroup) return null; if (!isGroup) return null;
const surfaceHint = ctx.Surface?.trim().toLowerCase(); const providerHint = ctx.Provider?.trim().toLowerCase();
const hasLegacyGroupPrefix = from.startsWith("group:"); const hasLegacyGroupPrefix = from.startsWith("group:");
const raw = ( const raw = (
hasLegacyGroupPrefix ? from.slice("group:".length) : from hasLegacyGroupPrefix ? from.slice("group:".length) : from
).trim(); ).trim();
let surface: string | undefined; let provider: string | undefined;
let kind: "group" | "channel" | undefined; let kind: "group" | "channel" | undefined;
let id = ""; let id = "";
@ -203,7 +253,7 @@ export function resolveGroupSessionKey(
const parseParts = (parts: string[]) => { const parseParts = (parts: string[]) => {
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
surface = parts[0]; provider = parts[0];
if (parts.length >= 3) { if (parts.length >= 3) {
const kindCandidate = parts[1]; const kindCandidate = parts[1];
if (["group", "channel"].includes(kindCandidate)) { if (["group", "channel"].includes(kindCandidate)) {
@ -239,8 +289,8 @@ export function resolveGroupSessionKey(
} }
} }
const resolvedSurface = surface ?? surfaceHint; const resolvedProvider = provider ?? providerHint;
if (!resolvedSurface) { if (!resolvedProvider) {
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`; const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
return { return {
key: legacy, key: legacy,
@ -251,7 +301,7 @@ export function resolveGroupSessionKey(
} }
const resolvedKind = kind === "channel" ? "channel" : "group"; const resolvedKind = kind === "channel" ? "channel" : "group";
const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`; const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
let legacyKey: string | undefined; let legacyKey: string | undefined;
if (hasLegacyGroupPrefix || from.includes("@g.us")) { if (hasLegacyGroupPrefix || from.includes("@g.us")) {
legacyKey = `group:${id || raw || from}`; legacyKey = `group:${id || raw || from}`;
@ -260,7 +310,7 @@ export function resolveGroupSessionKey(
return { return {
key, key,
legacyKey, legacyKey,
surface: resolvedSurface, provider: resolvedProvider,
id: id || raw || from, id: id || raw || from,
chatType: resolvedKind === "channel" ? "room" : "group", chatType: resolvedKind === "channel" ? "room" : "group",
}; };
@ -323,10 +373,11 @@ export async function saveSessionStore(
export async function updateLastRoute(params: { export async function updateLastRoute(params: {
storePath: string; storePath: string;
sessionKey: string; sessionKey: string;
channel: SessionEntry["lastChannel"]; provider: SessionEntry["lastProvider"];
to?: string; to?: string;
accountId?: string;
}) { }) {
const { storePath, sessionKey, channel, to } = params; const { storePath, sessionKey, provider, to, accountId } = params;
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const existing = store[sessionKey]; const existing = store[sessionKey];
const now = Date.now(); const now = Date.now();
@ -349,13 +400,16 @@ export async function updateLastRoute(params: {
contextTokens: existing?.contextTokens, contextTokens: existing?.contextTokens,
displayName: existing?.displayName, displayName: existing?.displayName,
chatType: existing?.chatType, chatType: existing?.chatType,
surface: existing?.surface, provider: existing?.provider,
subject: existing?.subject, subject: existing?.subject,
room: existing?.room, room: existing?.room,
space: existing?.space, space: existing?.space,
skillsSnapshot: existing?.skillsSnapshot, skillsSnapshot: existing?.skillsSnapshot,
lastChannel: channel, lastProvider: provider,
lastTo: to?.trim() ? to.trim() : undefined, lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? accountId.trim()
: existing?.lastAccountId,
}; };
store[sessionKey] = next; store[sessionKey] = next;
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
@ -384,12 +438,16 @@ export function resolveSessionKey(
if (explicit) return explicit; if (explicit) return explicit;
const raw = deriveSessionKey(scope, ctx); const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw; if (scope === "global") return raw;
// Default to a single shared direct-chat session called "main"; groups stay isolated. const canonicalMainKey =
const canonical = (mainKey ?? "main").trim() || "main"; (mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
const canonical = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: canonicalMainKey,
});
const isGroup = const isGroup =
raw.startsWith("group:") || raw.startsWith("group:") ||
raw.includes(":group:") || raw.includes(":group:") ||
raw.includes(":channel:"); raw.includes(":channel:");
if (!isGroup) return canonical; if (!isGroup) return canonical;
return raw; return `agent:${DEFAULT_AGENT_ID}:${raw}`;
} }

View File

@ -6,7 +6,7 @@ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = { export type SessionSendPolicyMatch = {
surface?: string; provider?: string;
chatType?: "direct" | "group" | "room"; chatType?: "direct" | "group" | "room";
keyPrefix?: string; keyPrefix?: string;
}; };
@ -178,7 +178,7 @@ export type HookMappingConfig = {
messageTemplate?: string; messageTemplate?: string;
textTemplate?: string; textTemplate?: string;
deliver?: boolean; deliver?: boolean;
channel?: provider?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -506,7 +506,7 @@ export type QueueMode =
| "interrupt"; | "interrupt";
export type QueueDropPolicy = "old" | "new" | "summarize"; export type QueueDropPolicy = "old" | "new" | "summarize";
export type QueueModeBySurface = { export type QueueModeByProvider = {
whatsapp?: QueueMode; whatsapp?: QueueMode;
telegram?: QueueMode; telegram?: QueueMode;
discord?: QueueMode; discord?: QueueMode;
@ -552,8 +552,8 @@ export type RoutingConfig = {
bindings?: Array<{ bindings?: Array<{
agentId: string; agentId: string;
match: { match: {
surface: string; provider: string;
surfaceAccountId?: string; accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string }; peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string; guildId?: string;
teamId?: string; teamId?: string;
@ -561,7 +561,7 @@ export type RoutingConfig = {
}>; }>;
queue?: { queue?: {
mode?: QueueMode; mode?: QueueMode;
bySurface?: QueueModeBySurface; byProvider?: QueueModeByProvider;
debounceMs?: number; debounceMs?: number;
cap?: number; cap?: number;
drop?: QueueDropPolicy; drop?: QueueDropPolicy;
@ -902,7 +902,7 @@ export type ClawdbotConfig = {
elevated?: { elevated?: {
/** Enable or disable elevated mode (default: true). */ /** Enable or disable elevated mode (default: true). */
enabled?: boolean; enabled?: boolean;
/** Approved senders for /elevated (per-surface allowlists). */ /** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig; allowFrom?: AgentElevatedAllowFromConfig;
}; };
/** Optional sandbox settings for non-main sessions. */ /** Optional sandbox settings for non-main sessions. */

View File

@ -130,7 +130,7 @@ const SessionSchema = z
action: z.union([z.literal("allow"), z.literal("deny")]), action: z.union([z.literal("allow"), z.literal("deny")]),
match: z match: z
.object({ .object({
surface: z.string().optional(), provider: z.string().optional(),
chatType: z chatType: z
.union([ .union([
z.literal("direct"), z.literal("direct"),
@ -240,8 +240,8 @@ const RoutingSchema = z
z.object({ z.object({
agentId: z.string(), agentId: z.string(),
match: z.object({ match: z.object({
surface: z.string(), provider: z.string(),
surfaceAccountId: z.string().optional(), accountId: z.string().optional(),
peer: z peer: z
.object({ .object({
kind: z.union([ kind: z.union([
@ -261,7 +261,7 @@ const RoutingSchema = z
queue: z queue: z
.object({ .object({
mode: QueueModeSchema.optional(), mode: QueueModeSchema.optional(),
bySurface: QueueModeBySurfaceSchema, byProvider: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(), debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(), cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(), drop: QueueDropSchema.optional(),
@ -288,7 +288,7 @@ const HookMappingSchema = z
messageTemplate: z.string().optional(), messageTemplate: z.string().optional(),
textTemplate: z.string().optional(), textTemplate: z.string().optional(),
deliver: z.boolean().optional(), deliver: z.boolean().optional(),
channel: z provider: z
.union([ .union([
z.literal("last"), z.literal("last"),
z.literal("whatsapp"), z.literal("whatsapp"),

View File

@ -9,22 +9,22 @@ type SchemaLike = {
const?: unknown; const?: unknown;
}; };
type ChannelSchema = { type ProviderSchema = {
anyOf?: Array<{ const?: unknown }>; anyOf?: Array<{ const?: unknown }>;
}; };
function extractCronChannels(schema: SchemaLike): string[] { function extractCronProviders(schema: SchemaLike): string[] {
const union = schema.anyOf ?? []; const union = schema.anyOf ?? [];
const payloadWithChannel = union.find((entry) => const payloadWithProvider = union.find((entry) =>
Boolean(entry?.properties && "channel" in entry.properties), Boolean(entry?.properties && "provider" in entry.properties),
); );
const channelSchema = payloadWithChannel?.properties const providerSchema = payloadWithProvider?.properties
? (payloadWithChannel.properties.channel as ChannelSchema) ? (payloadWithProvider.properties.provider as ProviderSchema)
: undefined; : undefined;
const channels = (channelSchema?.anyOf ?? []) const providers = (providerSchema?.anyOf ?? [])
.map((entry) => entry?.const) .map((entry) => entry?.const)
.filter((value): value is string => typeof value === "string"); .filter((value): value is string => typeof value === "string");
return channels; return providers;
} }
const UI_FILES = [ const UI_FILES = [
@ -36,28 +36,28 @@ const UI_FILES = [
const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"]; const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"];
describe("cron protocol conformance", () => { describe("cron protocol conformance", () => {
it("ui + swift include all cron channels from gateway schema", async () => { it("ui + swift include all cron providers from gateway schema", async () => {
const channels = extractCronChannels(CronPayloadSchema as SchemaLike); const providers = extractCronProviders(CronPayloadSchema as SchemaLike);
expect(channels.length).toBeGreaterThan(0); expect(providers.length).toBeGreaterThan(0);
const cwd = process.cwd(); const cwd = process.cwd();
for (const relPath of UI_FILES) { for (const relPath of UI_FILES) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) { for (const provider of providers) {
expect( expect(
content.includes(`"${channel}"`), content.includes(`"${provider}"`),
`${relPath} missing ${channel}`, `${relPath} missing ${provider}`,
).toBe(true); ).toBe(true);
} }
} }
for (const relPath of SWIFT_FILES) { for (const relPath of SWIFT_FILES) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) { for (const provider of providers) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); const pattern = new RegExp(`\\bcase\\s+${provider}\\b`);
expect( expect(
pattern.test(content), pattern.test(content),
`${relPath} missing case ${channel}`, `${relPath} missing case ${provider}`,
).toBe(true); ).toBe(true);
} }
} }

View File

@ -42,10 +42,10 @@ async function writeSessionStore(home: string) {
storePath, storePath,
JSON.stringify( JSON.stringify(
{ {
main: { "agent:main:main": {
sessionId: "main-session", sessionId: "main-session",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "webchat", lastProvider: "webchat",
lastTo: "", lastTo: "",
}, },
}, },
@ -224,7 +224,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
bestEffortDeliver: false, bestEffortDeliver: false,
}), }),
message: "do it", message: "do it",
@ -264,7 +264,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
bestEffortDeliver: true, bestEffortDeliver: true,
}), }),
message: "do it", message: "do it",
@ -309,7 +309,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "telegram", provider: "telegram",
to: "123", to: "123",
}), }),
message: "do it", message: "do it",
@ -361,7 +361,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "discord", provider: "discord",
to: "channel:1122", to: "channel:1122",
}), }),
message: "do it", message: "do it",
@ -406,7 +406,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "telegram", provider: "telegram",
to: "123", to: "123",
}), }),
message: "do it", message: "do it",
@ -450,7 +450,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+1234", to: "+1234",
}), }),
message: "do it", message: "do it",
@ -493,7 +493,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "telegram", provider: "telegram",
to: "123", to: "123",
}), }),
message: "do it", message: "do it",
@ -537,7 +537,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "telegram", provider: "telegram",
to: "123", to: "123",
}), }),
message: "do it", message: "do it",
@ -585,7 +585,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn", kind: "agentTurn",
message: "do it", message: "do it",
deliver: true, deliver: true,
channel: "telegram", provider: "telegram",
to: "123", to: "123",
}), }),
message: "do it", message: "do it",

View File

@ -29,6 +29,8 @@ import type { ClawdbotConfig } from "../config/config.js";
import { import {
DEFAULT_IDLE_MINUTES, DEFAULT_IDLE_MINUTES,
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
resolveSessionTranscriptPath, resolveSessionTranscriptPath,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
@ -87,7 +89,7 @@ function isHeartbeatOnlyResponse(
function resolveDeliveryTarget( function resolveDeliveryTarget(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
jobPayload: { jobPayload: {
channel?: provider?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -98,36 +100,37 @@ function resolveDeliveryTarget(
to?: string; to?: string;
}, },
) { ) {
const requestedChannel = const requestedProvider =
typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; typeof jobPayload.provider === "string" ? jobPayload.provider : "last";
const explicitTo = const explicitTo =
typeof jobPayload.to === "string" && jobPayload.to.trim() typeof jobPayload.to === "string" && jobPayload.to.trim()
? jobPayload.to.trim() ? jobPayload.to.trim()
: undefined; : undefined;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const mainSessionKey = resolveMainSessionKey(cfg);
const storePath = resolveStorePath(sessionCfg?.store); const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const main = store[mainKey]; const main = store[mainSessionKey];
const lastChannel = const lastProvider =
main?.lastChannel && main.lastChannel !== "webchat" main?.lastProvider && main.lastProvider !== "webchat"
? main.lastChannel ? main.lastProvider
: undefined; : undefined;
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : ""; const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
const channel = (() => { const provider = (() => {
if ( if (
requestedChannel === "whatsapp" || requestedProvider === "whatsapp" ||
requestedChannel === "telegram" || requestedProvider === "telegram" ||
requestedChannel === "discord" || requestedProvider === "discord" ||
requestedChannel === "slack" || requestedProvider === "slack" ||
requestedChannel === "signal" || requestedProvider === "signal" ||
requestedChannel === "imessage" requestedProvider === "imessage"
) { ) {
return requestedChannel; return requestedProvider;
} }
return lastChannel ?? "whatsapp"; return lastProvider ?? "whatsapp";
})(); })();
const to = (() => { const to = (() => {
@ -136,7 +139,7 @@ function resolveDeliveryTarget(
})(); })();
const sanitizedWhatsappTo = (() => { const sanitizedWhatsappTo = (() => {
if (channel !== "whatsapp") return to; if (provider !== "whatsapp") return to;
const rawAllow = cfg.whatsapp?.allowFrom ?? []; const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return to; if (rawAllow.includes("*")) return to;
const allowFrom = rawAllow const allowFrom = rawAllow
@ -150,8 +153,8 @@ function resolveDeliveryTarget(
})(); })();
return { return {
channel, provider,
to: channel === "whatsapp" ? sanitizedWhatsappTo : to, to: provider === "whatsapp" ? sanitizedWhatsappTo : to,
}; };
} }
@ -181,7 +184,7 @@ function resolveCronSession(params: {
model: entry?.model, model: entry?.model,
contextTokens: entry?.contextTokens, contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
}; };
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh }; return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
@ -251,9 +254,9 @@ export async function runCronIsolatedAgentTurn(params: {
params.job.payload.bestEffortDeliver === true; params.job.payload.bestEffortDeliver === true;
const resolvedDelivery = resolveDeliveryTarget(params.cfg, { const resolvedDelivery = resolveDeliveryTarget(params.cfg, {
channel: provider:
params.job.payload.kind === "agentTurn" params.job.payload.kind === "agentTurn"
? params.job.payload.channel ? params.job.payload.provider
: "last", : "last",
to: to:
params.job.payload.kind === "agentTurn" params.job.payload.kind === "agentTurn"
@ -302,7 +305,7 @@ export async function runCronIsolatedAgentTurn(params: {
registerAgentRunContext(cronSession.sessionEntry.sessionId, { registerAgentRunContext(cronSession.sessionEntry.sessionId, {
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
}); });
const surface = resolvedDelivery.channel; const messageProvider = resolvedDelivery.provider;
const fallbackResult = await runWithModelFallback({ const fallbackResult = await runWithModelFallback({
cfg: params.cfg, cfg: params.cfg,
provider, provider,
@ -311,7 +314,7 @@ export async function runCronIsolatedAgentTurn(params: {
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: cronSession.sessionEntry.sessionId, sessionId: cronSession.sessionEntry.sessionId,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
surface, messageProvider,
sessionFile, sessionFile,
workspaceDir, workspaceDir,
config: params.cfg, config: params.cfg,
@ -380,7 +383,7 @@ export async function runCronIsolatedAgentTurn(params: {
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
if (delivery && !skipHeartbeatDelivery) { if (delivery && !skipHeartbeatDelivery) {
if (resolvedDelivery.channel === "whatsapp") { if (resolvedDelivery.provider === "whatsapp") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) if (!bestEffortDeliver)
return { return {
@ -415,7 +418,7 @@ export async function runCronIsolatedAgentTurn(params: {
return { status: "error", summary, error: String(err) }; return { status: "error", summary, error: String(err) };
return { status: "ok", summary }; return { status: "ok", summary };
} }
} else if (resolvedDelivery.channel === "telegram") { } else if (resolvedDelivery.provider === "telegram") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) if (!bestEffortDeliver)
return { return {
@ -459,14 +462,14 @@ export async function runCronIsolatedAgentTurn(params: {
return { status: "error", summary, error: String(err) }; return { status: "error", summary, error: String(err) };
return { status: "ok", summary }; return { status: "ok", summary };
} }
} else if (resolvedDelivery.channel === "discord") { } else if (resolvedDelivery.provider === "discord") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) if (!bestEffortDeliver)
return { return {
status: "error", status: "error",
summary, summary,
error: error:
"Cron delivery to Discord requires --channel discord and --to <channelId|user:ID>", "Cron delivery to Discord requires --provider discord and --to <channelId|user:ID>",
}; };
return { return {
status: "skipped", status: "skipped",
@ -503,14 +506,14 @@ export async function runCronIsolatedAgentTurn(params: {
return { status: "error", summary, error: String(err) }; return { status: "error", summary, error: String(err) };
return { status: "ok", summary }; return { status: "ok", summary };
} }
} else if (resolvedDelivery.channel === "slack") { } else if (resolvedDelivery.provider === "slack") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) if (!bestEffortDeliver)
return { return {
status: "error", status: "error",
summary, summary,
error: error:
"Cron delivery to Slack requires --channel slack and --to <channelId|user:ID>", "Cron delivery to Slack requires --provider slack and --to <channelId|user:ID>",
}; };
return { return {
status: "skipped", status: "skipped",
@ -543,7 +546,7 @@ export async function runCronIsolatedAgentTurn(params: {
return { status: "error", summary, error: String(err) }; return { status: "error", summary, error: String(err) };
return { status: "ok", summary }; return { status: "ok", summary };
} }
} else if (resolvedDelivery.channel === "signal") { } else if (resolvedDelivery.provider === "signal") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) if (!bestEffortDeliver)
return { return {
@ -582,7 +585,7 @@ export async function runCronIsolatedAgentTurn(params: {
return { status: "error", summary, error: String(err) }; return { status: "error", summary, error: String(err) };
return { status: "ok", summary }; return { status: "ok", summary };
} }
} else if (resolvedDelivery.channel === "imessage") { } else if (resolvedDelivery.provider === "imessage") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) if (!bestEffortDeliver)
return { return {

View File

@ -14,7 +14,7 @@ export type CronPayload =
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
deliver?: boolean; deliver?: boolean;
channel?: provider?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"

View File

@ -31,11 +31,7 @@ import type {
ReplyToMode, ReplyToMode,
} from "../config/config.js"; } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
resolveSessionKey,
resolveStorePath,
updateLastRoute,
} from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js"; import { getChildLogger } from "../logging.js";
@ -45,6 +41,7 @@ import {
readProviderAllowFromStore, readProviderAllowFromStore,
upsertProviderPairingRequest, upsertProviderPairingRequest,
} from "../pairing/pairing-store.js"; } from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { sendMessageDiscord } from "./send.js"; import { sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js"; import { normalizeDiscordToken } from "./token.js";
@ -451,24 +448,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
} }
} }
const route = resolveAgentRoute({
cfg,
provider: "discord",
guildId: message.guildId ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? message.author.id : message.channelId,
},
});
const systemText = resolveDiscordSystemEvent(message); const systemText = resolveDiscordSystemEvent(message);
if (systemText) { if (systemText) {
const sessionCfg = cfg.session;
const sessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const sessionKey = resolveSessionKey(
sessionScope,
{
From: isDirectMessage
? `discord:${message.author.id}`
: `group:${message.channelId}`,
ChatType: isDirectMessage ? "direct" : "group",
Surface: "discord",
},
mainKey,
);
enqueueSystemEvent(systemText, { enqueueSystemEvent(systemText, {
sessionKey, sessionKey: route.sessionKey,
contextKey: `discord:system:${message.channelId}:${message.id}`, contextKey: `discord:system:${message.channelId}:${message.id}`,
}); });
return; return;
@ -514,7 +507,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const groupSubject = isDirectMessage ? undefined : groupRoom; const groupSubject = isDirectMessage ? undefined : groupRoom;
const messageText = text; const messageText = text;
let combinedBody = formatAgentEnvelope({ let combinedBody = formatAgentEnvelope({
surface: "Discord", provider: "Discord",
from: fromLabel, from: fromLabel,
timestamp: message.createdTimestamp, timestamp: message.createdTimestamp,
body: messageText, body: messageText,
@ -529,7 +522,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const historyText = historyWithoutCurrent const historyText = historyWithoutCurrent
.map((entry) => .map((entry) =>
formatAgentEnvelope({ formatAgentEnvelope({
surface: "Discord", provider: "Discord",
from: fromLabel, from: fromLabel,
timestamp: entry.timestamp, timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
@ -573,7 +566,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
? `${snapshotText}\n[${forwardMetaParts.join(" ")}]` ? `${snapshotText}\n[${forwardMetaParts.join(" ")}]`
: snapshotText; : snapshotText;
const forwardedEnvelope = formatAgentEnvelope({ const forwardedEnvelope = formatAgentEnvelope({
surface: "Discord", provider: "Discord",
from: `Forwarded by ${forwarder}`, from: `Forwarded by ${forwarder}`,
timestamp: timestamp:
forwardedSnapshot.snapshot.createdTimestamp ?? forwardedSnapshot.snapshot.createdTimestamp ??
@ -590,6 +583,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
? `discord:${message.author.id}` ? `discord:${message.author.id}`
: `group:${message.channelId}`, : `group:${message.channelId}`,
To: `channel:${message.channelId}`, To: `channel:${message.channelId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group", ChatType: isDirectMessage ? "direct" : "group",
SenderName: message.member?.displayName ?? message.author.tag, SenderName: message.member?.displayName ?? message.author.tag,
SenderId: message.author.id, SenderId: message.author.id,
@ -600,7 +595,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
GroupSpace: isGuildMessage GroupSpace: isGuildMessage
? (guildInfo?.id ?? guildSlug) || undefined ? (guildInfo?.id ?? guildSlug) || undefined
: undefined, : undefined,
Surface: "discord" as const, Provider: "discord" as const,
WasMentioned: wasMentioned, WasMentioned: wasMentioned,
MessageSid: message.id, MessageSid: message.id,
Timestamp: message.createdTimestamp, Timestamp: message.createdTimestamp,
@ -617,13 +612,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (isDirectMessage) { if (isDirectMessage) {
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store, {
const storePath = resolveStorePath(sessionCfg?.store); agentId: route.agentId,
});
await updateLastRoute({ await updateLastRoute({
storePath, storePath,
sessionKey: mainKey, sessionKey: route.mainSessionKey,
channel: "discord", provider: "discord",
to: `user:${message.author.id}`, to: `user:${message.author.id}`,
accountId: route.accountId,
}); });
} }
@ -766,20 +763,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const authorLabel = message.author?.tag ?? message.author?.username; const authorLabel = message.author?.tag ?? message.author?.username;
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`; const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
const sessionCfg = cfg.session; const route = resolveAgentRoute({
const sessionScope = sessionCfg?.scope ?? "per-sender"; cfg,
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; provider: "discord",
const sessionKey = resolveSessionKey( guildId: guild.id,
sessionScope, peer: { kind: "channel", id: message.channelId },
{ });
From: `group:${message.channelId}`,
ChatType: "group",
Surface: "discord",
},
mainKey,
);
enqueueSystemEvent(text, { enqueueSystemEvent(text, {
sessionKey, sessionKey: route.sessionKey,
contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`, contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`,
}); });
} catch (err) { } catch (err) {
@ -884,7 +875,7 @@ async function resolveReplyContext(message: Message): Promise<string | null> {
: (referenced.member?.displayName ?? referenced.author.tag); : (referenced.member?.displayName ?? referenced.author.tag);
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`; const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`;
return formatAgentEnvelope({ return formatAgentEnvelope({
surface: "Discord", provider: "Discord",
from: fromLabel, from: fromLabel,
timestamp: referenced.createdTimestamp, timestamp: referenced.createdTimestamp,
body, body,

View File

@ -18,7 +18,7 @@ export type HookMappingResolved = {
messageTemplate?: string; messageTemplate?: string;
textTemplate?: string; textTemplate?: string;
deliver?: boolean; deliver?: boolean;
channel?: provider?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -57,7 +57,7 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey?: string; sessionKey?: string;
deliver?: boolean; deliver?: boolean;
channel?: provider?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -101,7 +101,7 @@ type HookTransformResult = Partial<{
name: string; name: string;
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
channel: provider:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -196,7 +196,7 @@ function normalizeHookMapping(
messageTemplate: mapping.messageTemplate, messageTemplate: mapping.messageTemplate,
textTemplate: mapping.textTemplate, textTemplate: mapping.textTemplate,
deliver: mapping.deliver, deliver: mapping.deliver,
channel: mapping.channel, provider: mapping.provider,
to: mapping.to, to: mapping.to,
thinking: mapping.thinking, thinking: mapping.thinking,
timeoutSeconds: mapping.timeoutSeconds, timeoutSeconds: mapping.timeoutSeconds,
@ -241,7 +241,7 @@ function buildActionFromMapping(
wakeMode: mapping.wakeMode ?? "now", wakeMode: mapping.wakeMode ?? "now",
sessionKey: renderOptional(mapping.sessionKey, ctx), sessionKey: renderOptional(mapping.sessionKey, ctx),
deliver: mapping.deliver, deliver: mapping.deliver,
channel: mapping.channel, provider: mapping.provider,
to: renderOptional(mapping.to, ctx), to: renderOptional(mapping.to, ctx),
thinking: renderOptional(mapping.thinking, ctx), thinking: renderOptional(mapping.thinking, ctx),
timeoutSeconds: mapping.timeoutSeconds, timeoutSeconds: mapping.timeoutSeconds,
@ -291,7 +291,7 @@ function mergeAction(
typeof override.deliver === "boolean" typeof override.deliver === "boolean"
? override.deliver ? override.deliver
: baseAgent?.deliver, : baseAgent?.deliver,
channel: override.channel ?? baseAgent?.channel, provider: override.provider ?? baseAgent?.provider,
to: override.to ?? baseAgent?.to, to: override.to ?? baseAgent?.to,
thinking: override.thinking ?? baseAgent?.thinking, thinking: override.thinking ?? baseAgent?.thinking,
timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds,

View File

@ -56,7 +56,7 @@ describe("gateway hooks helpers", () => {
expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false); expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false);
}); });
test("normalizeAgentPayload defaults + validates channel", () => { test("normalizeAgentPayload defaults + validates provider", () => {
const ok = normalizeAgentPayload( const ok = normalizeAgentPayload(
{ message: "hello" }, { message: "hello" },
{ idFactory: () => "fixed" }, { idFactory: () => "fixed" },
@ -64,20 +64,20 @@ describe("gateway hooks helpers", () => {
expect(ok.ok).toBe(true); expect(ok.ok).toBe(true);
if (ok.ok) { if (ok.ok) {
expect(ok.value.sessionKey).toBe("hook:fixed"); expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.channel).toBe("last"); expect(ok.value.provider).toBe("last");
expect(ok.value.name).toBe("Hook"); expect(ok.value.name).toBe("Hook");
} }
const imsg = normalizeAgentPayload( const imsg = normalizeAgentPayload(
{ message: "yo", channel: "imsg" }, { message: "yo", provider: "imsg" },
{ idFactory: () => "x" }, { idFactory: () => "x" },
); );
expect(imsg.ok).toBe(true); expect(imsg.ok).toBe(true);
if (imsg.ok) { if (imsg.ok) {
expect(imsg.value.channel).toBe("imessage"); expect(imsg.value.provider).toBe("imessage");
} }
const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); const bad = normalizeAgentPayload({ message: "yo", provider: "sms" });
expect(bad.ok).toBe(false); expect(bad.ok).toBe(false);
}); });
}); });

View File

@ -137,7 +137,7 @@ export type HookAgentPayload = {
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
channel: provider:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -173,26 +173,26 @@ export function normalizeAgentPayload(
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim() ? sessionKeyRaw.trim()
: `hook:${idFactory()}`; : `hook:${idFactory()}`;
const channelRaw = payload.channel; const providerRaw = payload.provider;
const channel = const provider =
channelRaw === "whatsapp" || providerRaw === "whatsapp" ||
channelRaw === "telegram" || providerRaw === "telegram" ||
channelRaw === "discord" || providerRaw === "discord" ||
channelRaw === "slack" || providerRaw === "slack" ||
channelRaw === "signal" || providerRaw === "signal" ||
channelRaw === "imessage" || providerRaw === "imessage" ||
channelRaw === "last" providerRaw === "last"
? channelRaw ? providerRaw
: channelRaw === "imsg" : providerRaw === "imsg"
? "imessage" ? "imessage"
: channelRaw === undefined : providerRaw === undefined
? "last" ? "last"
: null; : null;
if (channel === null) { if (provider === null) {
return { return {
ok: false, ok: false,
error: error:
"channel must be last|whatsapp|telegram|discord|slack|signal|imessage", "provider must be last|whatsapp|telegram|discord|slack|signal|imessage",
}; };
} }
const toRaw = payload.to; const toRaw = payload.to;
@ -219,7 +219,7 @@ export function normalizeAgentPayload(
wakeMode, wakeMode,
sessionKey, sessionKey,
deliver, deliver,
channel, provider,
to, to,
thinking, thinking,
timeoutSeconds, timeoutSeconds,

View File

@ -193,6 +193,7 @@ export const SendParamsSchema = Type.Object(
mediaUrl: Type.Optional(Type.String()), mediaUrl: Type.Optional(Type.String()),
gifPlayback: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()),
provider: Type.Optional(Type.String()), provider: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString, idempotencyKey: NonEmptyString,
}, },
{ additionalProperties: false }, { additionalProperties: false },
@ -206,6 +207,7 @@ export const PollParamsSchema = Type.Object(
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })), maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
durationHours: Type.Optional(Type.Integer({ minimum: 1 })), durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
provider: Type.Optional(Type.String()), provider: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString, idempotencyKey: NonEmptyString,
}, },
{ additionalProperties: false }, { additionalProperties: false },
@ -218,7 +220,7 @@ export const AgentParamsSchema = Type.Object(
sessionKey: Type.Optional(Type.String()), sessionKey: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()), deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional(Type.String()), provider: Type.Optional(Type.String()),
timeout: Type.Optional(Type.Integer({ minimum: 0 })), timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()), lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()),
@ -543,6 +545,7 @@ export const WebLoginStartParamsSchema = Type.Object(
force: Type.Optional(Type.Boolean()), force: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
verbose: Type.Optional(Type.Boolean()), verbose: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@ -550,6 +553,7 @@ export const WebLoginStartParamsSchema = Type.Object(
export const WebLoginWaitParamsSchema = Type.Object( export const WebLoginWaitParamsSchema = Type.Object(
{ {
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
accountId: Type.Optional(Type.String()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@ -642,7 +646,7 @@ export const CronPayloadSchema = Type.Union([
thinking: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()), deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional( provider: Type.Optional(
Type.Union([ Type.Union([
Type.Literal("last"), Type.Literal("last"),
Type.Literal("whatsapp"), Type.Literal("whatsapp"),

View File

@ -48,6 +48,7 @@ import {
setVoiceWakeTriggers, setVoiceWakeTriggers,
} from "../infra/voicewake.js"; } from "../infra/voicewake.js";
import { clearCommandLane } from "../process/command-queue.js"; import { clearCommandLane } from "../process/command-queue.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { buildMessageWithAttachments } from "./chat-attachments.js"; import { buildMessageWithAttachments } from "./chat-attachments.js";
@ -372,7 +373,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
}, },
}; };
} }
if (!key.startsWith("subagent:")) { if (!isSubagentSessionKey(key)) {
return { return {
ok: false, ok: false,
error: { error: {
@ -606,11 +607,11 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
displayName: entry?.displayName, displayName: entry?.displayName,
chatType: entry?.chatType, chatType: entry?.chatType,
surface: entry?.surface, provider: entry?.provider,
subject: entry?.subject, subject: entry?.subject,
room: entry?.room, room: entry?.room,
space: entry?.space, space: entry?.space,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot, skillsSnapshot: entry?.skillsSnapshot,
}; };
@ -986,7 +987,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
thinkingLevel: entry?.thinkingLevel, thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel, verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent, systemSent: entry?.systemSent,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
}; };
const clientRunId = p.idempotencyKey; const clientRunId = p.idempotencyKey;
@ -1033,7 +1034,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
thinking: p.thinking, thinking: p.thinking,
deliver: p.deliver, deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(), timeout: Math.ceil(timeoutMs / 1000).toString(),
surface: `Node(${nodeId})`, messageProvider: `node(${nodeId})`,
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
defaultRuntime, defaultRuntime,
@ -1126,7 +1127,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
verboseLevel: entry?.verboseLevel, verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent, systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
}; };
if (storePath) { if (storePath) {
@ -1146,7 +1147,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
sessionId, sessionId,
thinking: "low", thinking: "low",
deliver: false, deliver: false,
surface: "Node", messageProvider: "node",
}, },
defaultRuntime, defaultRuntime,
ctx.deps, ctx.deps,
@ -1208,7 +1209,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
verboseLevel: entry?.verboseLevel, verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent, systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
}; };
if (storePath) { if (storePath) {
@ -1227,7 +1228,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
typeof link?.timeoutSeconds === "number" typeof link?.timeoutSeconds === "number"
? link.timeoutSeconds.toString() ? link.timeoutSeconds.toString()
: undefined, : undefined,
surface: "Node", messageProvider: "node",
}, },
defaultRuntime, defaultRuntime,
ctx.deps, ctx.deps,

View File

@ -32,7 +32,7 @@ type HookDispatchers = {
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
channel: provider:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -175,7 +175,7 @@ export function createHooksRequestHandler(
wakeMode: mapped.action.wakeMode, wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "", sessionKey: mapped.action.sessionKey ?? "",
deliver: mapped.action.deliver === true, deliver: mapped.action.deliver === true,
channel: mapped.action.channel ?? "last", provider: mapped.action.provider ?? "last",
to: mapped.action.to, to: mapped.action.to,
thinking: mapped.action.thinking, thinking: mapped.action.thinking,
timeoutSeconds: mapped.action.timeoutSeconds, timeoutSeconds: mapped.action.timeoutSeconds,

View File

@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto";
import { agentCommand } from "../../commands/agent.js"; import { agentCommand } from "../../commands/agent.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
@ -41,7 +46,7 @@ export const agentHandlers: GatewayRequestHandlers = {
sessionKey?: string; sessionKey?: string;
thinking?: string; thinking?: string;
deliver?: boolean; deliver?: boolean;
channel?: string; provider?: string;
lane?: string; lane?: string;
extraSystemPrompt?: string; extraSystemPrompt?: string;
idempotencyKey: string; idempotencyKey: string;
@ -72,7 +77,7 @@ export const agentHandlers: GatewayRequestHandlers = {
cfgForAgent = cfg; cfgForAgent = cfg;
const now = Date.now(); const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID(); const sessionId = entry?.sessionId ?? randomUUID();
sessionEntry = { const nextEntry: SessionEntry = {
sessionId, sessionId,
updatedAt: now, updatedAt: now,
thinkingLevel: entry?.thinkingLevel, thinkingLevel: entry?.thinkingLevel,
@ -80,14 +85,15 @@ export const agentHandlers: GatewayRequestHandlers = {
systemSent: entry?.systemSent, systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
skillsSnapshot: entry?.skillsSnapshot, skillsSnapshot: entry?.skillsSnapshot,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
}; };
sessionEntry = nextEntry;
const sendPolicy = resolveSendPolicy({ const sendPolicy = resolveSendPolicy({
cfg, cfg,
entry, entry,
sessionKey: requestedSessionKey, sessionKey: requestedSessionKey,
surface: entry?.surface, provider: entry?.provider,
chatType: entry?.chatType, chatType: entry?.chatType,
}); });
if (sendPolicy === "deny") { if (sendPolicy === "deny") {
@ -102,14 +108,22 @@ export const agentHandlers: GatewayRequestHandlers = {
return; return;
} }
if (store) { if (store) {
store[requestedSessionKey] = sessionEntry; store[requestedSessionKey] = nextEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
} }
} }
resolvedSessionId = sessionId; resolvedSessionId = sessionId;
const mainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; const agentId = resolveAgentIdFromSessionKey(requestedSessionKey);
if (requestedSessionKey === mainKey) { const mainSessionKey = resolveAgentMainSessionKey({
cfg,
agentId,
});
const rawMainKey = (cfg.session?.mainKey ?? "main").trim() || "main";
if (
requestedSessionKey === mainSessionKey ||
requestedSessionKey === rawMainKey
) {
context.addChatRun(idem, { context.addChatRun(idem, {
sessionKey: requestedSessionKey, sessionKey: requestedSessionKey,
clientRunId: idem, clientRunId: idem,
@ -121,42 +135,42 @@ export const agentHandlers: GatewayRequestHandlers = {
const runId = idem; const runId = idem;
const requestedChannelRaw = const requestedProviderRaw =
typeof request.channel === "string" ? request.channel.trim() : ""; typeof request.provider === "string" ? request.provider.trim() : "";
const requestedChannelNormalized = requestedChannelRaw const requestedProviderNormalized = requestedProviderRaw
? requestedChannelRaw.toLowerCase() ? requestedProviderRaw.toLowerCase()
: "last"; : "last";
const requestedChannel = const requestedProvider =
requestedChannelNormalized === "imsg" requestedProviderNormalized === "imsg"
? "imessage" ? "imessage"
: requestedChannelNormalized; : requestedProviderNormalized;
const lastChannel = sessionEntry?.lastChannel; const lastProvider = sessionEntry?.lastProvider;
const lastTo = const lastTo =
typeof sessionEntry?.lastTo === "string" typeof sessionEntry?.lastTo === "string"
? sessionEntry.lastTo.trim() ? sessionEntry.lastTo.trim()
: ""; : "";
const resolvedChannel = (() => { const resolvedProvider = (() => {
if (requestedChannel === "last") { if (requestedProvider === "last") {
// WebChat is not a deliverable surface. Treat it as "unset" for routing, // WebChat is not a deliverable surface. Treat it as "unset" for routing,
// so VoiceWake and CLI callers don't get stuck with deliver=false. // so VoiceWake and CLI callers don't get stuck with deliver=false.
return lastChannel && lastChannel !== "webchat" return lastProvider && lastProvider !== "webchat"
? lastChannel ? lastProvider
: "whatsapp"; : "whatsapp";
} }
if ( if (
requestedChannel === "whatsapp" || requestedProvider === "whatsapp" ||
requestedChannel === "telegram" || requestedProvider === "telegram" ||
requestedChannel === "discord" || requestedProvider === "discord" ||
requestedChannel === "signal" || requestedProvider === "signal" ||
requestedChannel === "imessage" || requestedProvider === "imessage" ||
requestedChannel === "webchat" requestedProvider === "webchat"
) { ) {
return requestedChannel; return requestedProvider;
} }
return lastChannel && lastChannel !== "webchat" return lastProvider && lastProvider !== "webchat"
? lastChannel ? lastProvider
: "whatsapp"; : "whatsapp";
})(); })();
@ -167,11 +181,11 @@ export const agentHandlers: GatewayRequestHandlers = {
: undefined; : undefined;
if (explicit) return explicit; if (explicit) return explicit;
if ( if (
resolvedChannel === "whatsapp" || resolvedProvider === "whatsapp" ||
resolvedChannel === "telegram" || resolvedProvider === "telegram" ||
resolvedChannel === "discord" || resolvedProvider === "discord" ||
resolvedChannel === "signal" || resolvedProvider === "signal" ||
resolvedChannel === "imessage" resolvedProvider === "imessage"
) { ) {
return lastTo || undefined; return lastTo || undefined;
} }
@ -182,7 +196,7 @@ export const agentHandlers: GatewayRequestHandlers = {
// If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid
// for the configured allowlist. Otherwise, fall back to the first allowed number so // for the configured allowlist. Otherwise, fall back to the first allowed number so
// voice wake doesn't silently route to stale/test recipients. // voice wake doesn't silently route to stale/test recipients.
if (resolvedChannel !== "whatsapp") return resolvedTo; if (resolvedProvider !== "whatsapp") return resolvedTo;
const explicit = const explicit =
typeof request.to === "string" && request.to.trim() typeof request.to === "string" && request.to.trim()
? request.to.trim() ? request.to.trim()
@ -207,7 +221,7 @@ export const agentHandlers: GatewayRequestHandlers = {
return allowFrom[0]; return allowFrom[0];
})(); })();
const deliver = request.deliver === true && resolvedChannel !== "webchat"; const deliver = request.deliver === true && resolvedProvider !== "webchat";
const accepted = { const accepted = {
runId, runId,
@ -229,10 +243,10 @@ export const agentHandlers: GatewayRequestHandlers = {
sessionId: resolvedSessionId, sessionId: resolvedSessionId,
thinking: request.thinking, thinking: request.thinking,
deliver, deliver,
provider: resolvedChannel, provider: resolvedProvider,
timeout: request.timeout?.toString(), timeout: request.timeout?.toString(),
bestEffortDeliver, bestEffortDeliver,
surface: "VoiceWake", messageProvider: "voicewake",
runId, runId,
lane: request.lane, lane: request.lane,
extraSystemPrompt: request.extraSystemPrompt, extraSystemPrompt: request.extraSystemPrompt,

View File

@ -202,7 +202,7 @@ export const chatHandlers: GatewayRequestHandlers = {
verboseLevel: entry?.verboseLevel, verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent, systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
}; };
const clientRunId = p.idempotencyKey; const clientRunId = p.idempotencyKey;
@ -212,7 +212,7 @@ export const chatHandlers: GatewayRequestHandlers = {
cfg, cfg,
entry, entry,
sessionKey: p.sessionKey, sessionKey: p.sessionKey,
surface: entry?.surface, provider: entry?.provider,
chatType: entry?.chatType, chatType: entry?.chatType,
}); });
if (sendPolicy === "deny") { if (sendPolicy === "deny") {
@ -262,7 +262,7 @@ export const chatHandlers: GatewayRequestHandlers = {
thinking: p.thinking, thinking: p.thinking,
deliver: p.deliver, deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(), timeout: Math.ceil(timeoutMs / 1000).toString(),
surface: "WebChat", messageProvider: "webchat",
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
defaultRuntime, defaultRuntime,

View File

@ -6,7 +6,6 @@ import {
} from "../../config/config.js"; } from "../../config/config.js";
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js"; import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js"; import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
import { webAuthExists } from "../../providers/web/index.js";
import { probeSignal, type SignalProbe } from "../../signal/probe.js"; import { probeSignal, type SignalProbe } from "../../signal/probe.js";
import { probeSlack, type SlackProbe } from "../../slack/probe.js"; import { probeSlack, type SlackProbe } from "../../slack/probe.js";
import { import {
@ -15,7 +14,15 @@ import {
} from "../../slack/token.js"; } from "../../slack/token.js";
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramToken } from "../../telegram/token.js";
import { getWebAuthAgeMs, readWebSelfId } from "../../web/session.js"; import {
listEnabledWhatsAppAccounts,
resolveDefaultWhatsAppAccountId,
} from "../../web/accounts.js";
import {
getWebAuthAgeMs,
readWebSelfId,
webAuthExists,
} from "../../web/session.js";
import { import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
@ -148,10 +155,55 @@ export const providersHandlers: GatewayRequestHandlers = {
imessageLastProbeAt = Date.now(); imessageLastProbeAt = Date.now();
} }
const linked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs();
const self = readWebSelfId();
const runtime = context.getRuntimeSnapshot(); const runtime = context.getRuntimeSnapshot();
const defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg);
const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg);
const defaultWhatsAppAccount =
enabledWhatsAppAccounts.find(
(account) => account.accountId === defaultWhatsAppAccountId,
) ?? enabledWhatsAppAccounts[0];
const linked = defaultWhatsAppAccount
? await webAuthExists(defaultWhatsAppAccount.authDir)
: false;
const authAgeMs = defaultWhatsAppAccount
? getWebAuthAgeMs(defaultWhatsAppAccount.authDir)
: null;
const self = defaultWhatsAppAccount
? readWebSelfId(defaultWhatsAppAccount.authDir)
: { e164: null, jid: null };
const defaultWhatsAppStatus = {
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
} as const;
const whatsappAccounts = await Promise.all(
enabledWhatsAppAccounts.map(async (account) => {
const rt =
runtime.whatsappAccounts?.[account.accountId] ??
defaultWhatsAppStatus;
return {
accountId: account.accountId,
enabled: account.enabled,
linked: await webAuthExists(account.authDir),
authAgeMs: getWebAuthAgeMs(account.authDir),
self: readWebSelfId(account.authDir),
running: rt.running,
connected: rt.connected,
lastConnectedAt: rt.lastConnectedAt ?? null,
lastDisconnect: rt.lastDisconnect ?? null,
reconnectAttempts: rt.reconnectAttempts,
lastMessageAt: rt.lastMessageAt ?? null,
lastEventAt: rt.lastEventAt ?? null,
lastError: rt.lastError ?? null,
};
}),
);
respond( respond(
true, true,
@ -171,6 +223,8 @@ export const providersHandlers: GatewayRequestHandlers = {
lastEventAt: runtime.whatsapp.lastEventAt ?? null, lastEventAt: runtime.whatsapp.lastEventAt ?? null,
lastError: runtime.whatsapp.lastError ?? null, lastError: runtime.whatsapp.lastError ?? null,
}, },
whatsappAccounts,
whatsappDefaultAccountId: defaultWhatsAppAccountId,
telegram: { telegram: {
configured: telegramEnabled && Boolean(telegramToken), configured: telegramEnabled && Boolean(telegramToken),
tokenSource, tokenSource,

View File

@ -6,6 +6,7 @@ import { sendMessageSignal } from "../../signal/index.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js"; import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramToken } from "../../telegram/token.js";
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { import {
ErrorCodes, ErrorCodes,
@ -37,6 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = {
mediaUrl?: string; mediaUrl?: string;
gifPlayback?: boolean; gifPlayback?: boolean;
provider?: string; provider?: string;
accountId?: string;
idempotencyKey: string; idempotencyKey: string;
}; };
const idem = request.idempotencyKey; const idem = request.idempotencyKey;
@ -148,10 +150,17 @@ export const sendHandlers: GatewayRequestHandlers = {
}); });
respond(true, payload, undefined, { provider }); respond(true, payload, undefined, { provider });
} else { } else {
const cfg = loadConfig();
const accountId =
typeof request.accountId === "string" &&
request.accountId.trim().length > 0
? request.accountId.trim()
: resolveDefaultWhatsAppAccountId(cfg);
const result = await sendMessageWhatsApp(to, message, { const result = await sendMessageWhatsApp(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
verbose: shouldLogVerbose(), verbose: shouldLogVerbose(),
gifPlayback: request.gifPlayback, gifPlayback: request.gifPlayback,
accountId,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -199,6 +208,7 @@ export const sendHandlers: GatewayRequestHandlers = {
maxSelections?: number; maxSelections?: number;
durationHours?: number; durationHours?: number;
provider?: string; provider?: string;
accountId?: string;
idempotencyKey: string; idempotencyKey: string;
}; };
const idem = request.idempotencyKey; const idem = request.idempotencyKey;
@ -245,8 +255,15 @@ export const sendHandlers: GatewayRequestHandlers = {
}); });
respond(true, payload, undefined, { provider }); respond(true, payload, undefined, { provider });
} else { } else {
const cfg = loadConfig();
const accountId =
typeof request.accountId === "string" &&
request.accountId.trim().length > 0
? request.accountId.trim()
: resolveDefaultWhatsAppAccountId(cfg);
const result = await sendPollWhatsApp(to, poll, { const result = await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(), verbose: shouldLogVerbose(),
accountId,
}); });
const payload = { const payload = {
runId: idem, runId: idem,

View File

@ -24,11 +24,11 @@ import { loadConfig } from "../../config/config.js";
import { import {
loadSessionStore, loadSessionStore,
resolveMainSessionKey, resolveMainSessionKey,
resolveStorePath,
type SessionEntry, type SessionEntry,
saveSessionStore, saveSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { clearCommandLane } from "../../process/command-queue.js"; import { clearCommandLane } from "../../process/command-queue.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { normalizeSendPolicy } from "../../sessions/send-policy.js"; import { normalizeSendPolicy } from "../../sessions/send-policy.js";
import { import {
ErrorCodes, ErrorCodes,
@ -43,7 +43,8 @@ import {
import { import {
archiveFileOnDisk, archiveFileOnDisk,
listSessionsFromStore, listSessionsFromStore,
loadSessionEntry, loadCombinedSessionStoreForGateway,
resolveGatewaySessionStoreTarget,
resolveSessionTranscriptCandidates, resolveSessionTranscriptCandidates,
type SessionsPatchResult, type SessionsPatchResult,
} from "../session-utils.js"; } from "../session-utils.js";
@ -64,8 +65,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
} }
const p = params as import("../protocol/index.js").SessionsListParams; const p = params as import("../protocol/index.js").SessionsListParams;
const cfg = loadConfig(); const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store); const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const store = loadSessionStore(storePath);
const result = listSessionsFromStore({ const result = listSessionsFromStore({
cfg, cfg,
storePath, storePath,
@ -98,11 +98,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
} }
const cfg = loadConfig(); const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store); const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const now = Date.now(); const now = Date.now();
const existing = store[key]; const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const existing = store[primaryKey];
const next: SessionEntry = existing const next: SessionEntry = existing
? { ? {
...existing, ...existing,
@ -134,7 +141,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
if (!key.startsWith("subagent:")) { if (!isSubagentSessionKey(primaryKey)) {
respond( respond(
false, false,
undefined, undefined,
@ -311,12 +318,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
} }
} }
store[key] = next; store[primaryKey] = next;
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
const result: SessionsPatchResult = { const result: SessionsPatchResult = {
ok: true, ok: true,
path: storePath, path: storePath,
key, key: target.canonicalKey,
entry: next, entry: next,
}; };
respond(true, result, undefined); respond(true, result, undefined);
@ -344,7 +351,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return; return;
} }
const { storePath, store, entry } = loadSessionEntry(key); const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const store = loadSessionStore(storePath);
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entry = store[primaryKey];
const now = Date.now(); const now = Date.now();
const next: SessionEntry = { const next: SessionEntry = {
sessionId: randomUUID(), sessionId: randomUUID(),
@ -356,13 +373,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
model: entry?.model, model: entry?.model,
contextTokens: entry?.contextTokens, contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy, sendPolicy: entry?.sendPolicy,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot, skillsSnapshot: entry?.skillsSnapshot,
}; };
store[key] = next; store[primaryKey] = next;
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
respond(true, { ok: true, key, entry: next }, undefined); respond(
true,
{ ok: true, key: target.canonicalKey, entry: next },
undefined,
);
}, },
"sessions.delete": async ({ params, respond }) => { "sessions.delete": async ({ params, respond }) => {
if (!validateSessionsDeleteParams(params)) { if (!validateSessionsDeleteParams(params)) {
@ -387,8 +408,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return; return;
} }
const mainKey = resolveMainSessionKey(loadConfig()); const cfg = loadConfig();
if (key === mainKey) { const mainKey = resolveMainSessionKey(cfg);
const target = resolveGatewaySessionStoreTarget({ cfg, key });
if (target.canonicalKey === mainKey) {
respond( respond(
false, false,
undefined, undefined,
@ -403,10 +426,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const deleteTranscript = const deleteTranscript =
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const { storePath, store, entry } = loadSessionEntry(key); const storePath = target.storePath;
const store = loadSessionStore(storePath);
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entry = store[primaryKey];
const sessionId = entry?.sessionId; const sessionId = entry?.sessionId;
const existed = Boolean(store[key]); const existed = Boolean(entry);
clearCommandLane(resolveEmbeddedSessionLane(key)); clearCommandLane(resolveEmbeddedSessionLane(target.canonicalKey));
if (sessionId && isEmbeddedPiRunActive(sessionId)) { if (sessionId && isEmbeddedPiRunActive(sessionId)) {
abortEmbeddedPiRun(sessionId); abortEmbeddedPiRun(sessionId);
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000); const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
@ -422,7 +453,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return; return;
} }
} }
if (existed) delete store[key]; if (existed) delete store[primaryKey];
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
const archived: string[] = []; const archived: string[] = [];
@ -430,6 +461,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
for (const candidate of resolveSessionTranscriptCandidates( for (const candidate of resolveSessionTranscriptCandidates(
sessionId, sessionId,
storePath, storePath,
target.agentId,
)) { )) {
if (!fs.existsSync(candidate)) continue; if (!fs.existsSync(candidate)) continue;
try { try {
@ -440,7 +472,11 @@ export const sessionsHandlers: GatewayRequestHandlers = {
} }
} }
respond(true, { ok: true, key, deleted: existed, archived }, undefined); respond(
true,
{ ok: true, key: target.canonicalKey, deleted: existed, archived },
undefined,
);
}, },
"sessions.compact": async ({ params, respond }) => { "sessions.compact": async ({ params, respond }) => {
if (!validateSessionsCompactParams(params)) { if (!validateSessionsCompactParams(params)) {
@ -470,12 +506,27 @@ export const sessionsHandlers: GatewayRequestHandlers = {
? Math.max(1, Math.floor(p.maxLines)) ? Math.max(1, Math.floor(p.maxLines))
: 400; : 400;
const { storePath, store, entry } = loadSessionEntry(key); const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const store = loadSessionStore(storePath);
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entry = store[primaryKey];
const sessionId = entry?.sessionId; const sessionId = entry?.sessionId;
if (!sessionId) { if (!sessionId) {
respond( respond(
true, true,
{ ok: true, key, compacted: false, reason: "no sessionId" }, {
ok: true,
key: target.canonicalKey,
compacted: false,
reason: "no sessionId",
},
undefined, undefined,
); );
return; return;
@ -484,11 +535,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const filePath = resolveSessionTranscriptCandidates( const filePath = resolveSessionTranscriptCandidates(
sessionId, sessionId,
storePath, storePath,
target.agentId,
).find((candidate) => fs.existsSync(candidate)); ).find((candidate) => fs.existsSync(candidate));
if (!filePath) { if (!filePath) {
respond( respond(
true, true,
{ ok: true, key, compacted: false, reason: "no transcript" }, {
ok: true,
key: target.canonicalKey,
compacted: false,
reason: "no transcript",
},
undefined, undefined,
); );
return; return;
@ -499,7 +556,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
if (lines.length <= maxLines) { if (lines.length <= maxLines) {
respond( respond(
true, true,
{ ok: true, key, compacted: false, kept: lines.length }, {
ok: true,
key: target.canonicalKey,
compacted: false,
kept: lines.length,
},
undefined, undefined,
); );
return; return;
@ -509,11 +571,11 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const keptLines = lines.slice(-maxLines); const keptLines = lines.slice(-maxLines);
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
if (store[key]) { if (store[primaryKey]) {
delete store[key].inputTokens; delete store[primaryKey].inputTokens;
delete store[key].outputTokens; delete store[primaryKey].outputTokens;
delete store[key].totalTokens; delete store[primaryKey].totalTokens;
store[key].updatedAt = Date.now(); store[primaryKey].updatedAt = Date.now();
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
} }
@ -521,7 +583,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
true, true,
{ {
ok: true, ok: true,
key, key: target.canonicalKey,
compacted: true, compacted: true,
archived, archived,
kept: keptLines.length, kept: keptLines.length,

View File

@ -69,10 +69,10 @@ export type GatewayRequestContext = {
findRunningWizard: () => string | null; findRunningWizard: () => string | null;
purgeWizardSession: (id: string) => void; purgeWizardSession: (id: string) => void;
getRuntimeSnapshot: () => ProviderRuntimeSnapshot; getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startWhatsAppProvider: () => Promise<void>; startWhatsAppProvider: (accountId?: string) => Promise<void>;
stopWhatsAppProvider: () => Promise<void>; stopWhatsAppProvider: (accountId?: string) => Promise<void>;
stopTelegramProvider: () => Promise<void>; stopTelegramProvider: () => Promise<void>;
markWhatsAppLoggedOut: (cleared: boolean) => void; markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
wizardRunner: ( wizardRunner: (
opts: import("../../commands/onboard-types.js").OnboardOptions, opts: import("../../commands/onboard-types.js").OnboardOptions,
runtime: import("../../runtime.js").RuntimeEnv, runtime: import("../../runtime.js").RuntimeEnv,

View File

@ -1,4 +1,6 @@
import { loadConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
import { logoutWeb } from "../../web/session.js"; import { logoutWeb } from "../../web/session.js";
import { import {
@ -25,7 +27,11 @@ export const webHandlers: GatewayRequestHandlers = {
return; return;
} }
try { try {
await context.stopWhatsAppProvider(); const accountId =
typeof (params as { accountId?: unknown }).accountId === "string"
? (params as { accountId?: string }).accountId
: undefined;
await context.stopWhatsAppProvider(accountId);
const result = await startWebLoginWithQr({ const result = await startWebLoginWithQr({
force: Boolean((params as { force?: boolean }).force), force: Boolean((params as { force?: boolean }).force),
timeoutMs: timeoutMs:
@ -33,6 +39,7 @@ export const webHandlers: GatewayRequestHandlers = {
? (params as { timeoutMs?: number }).timeoutMs ? (params as { timeoutMs?: number }).timeoutMs
: undefined, : undefined,
verbose: Boolean((params as { verbose?: boolean }).verbose), verbose: Boolean((params as { verbose?: boolean }).verbose),
accountId,
}); });
respond(true, result, undefined); respond(true, result, undefined);
} catch (err) { } catch (err) {
@ -56,14 +63,19 @@ export const webHandlers: GatewayRequestHandlers = {
return; return;
} }
try { try {
const accountId =
typeof (params as { accountId?: unknown }).accountId === "string"
? (params as { accountId?: string }).accountId
: undefined;
const result = await waitForWebLogin({ const result = await waitForWebLogin({
timeoutMs: timeoutMs:
typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" typeof (params as { timeoutMs?: unknown }).timeoutMs === "number"
? (params as { timeoutMs?: number }).timeoutMs ? (params as { timeoutMs?: number }).timeoutMs
: undefined, : undefined,
accountId,
}); });
if (result.connected) { if (result.connected) {
await context.startWhatsAppProvider(); await context.startWhatsAppProvider(accountId);
} }
respond(true, result, undefined); respond(true, result, undefined);
} catch (err) { } catch (err) {
@ -74,11 +86,26 @@ export const webHandlers: GatewayRequestHandlers = {
); );
} }
}, },
"web.logout": async ({ respond, context }) => { "web.logout": async ({ params, respond, context }) => {
try { try {
await context.stopWhatsAppProvider(); const rawAccountId =
const cleared = await logoutWeb(defaultRuntime); params && typeof params === "object" && "accountId" in params
context.markWhatsAppLoggedOut(cleared); ? (params as { accountId?: unknown }).accountId
: undefined;
const accountId =
typeof rawAccountId === "string" ? rawAccountId.trim() : "";
const cfg = loadConfig();
const account = resolveWhatsAppAccount({
cfg,
accountId: accountId || undefined,
});
await context.stopWhatsAppProvider(account.accountId);
const cleared = await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
runtime: defaultRuntime,
});
context.markWhatsAppLoggedOut(cleared, account.accountId);
respond(true, { cleared }, undefined); respond(true, { cleared }, undefined);
} catch (err) { } catch (err) {
respond( respond(

View File

@ -15,6 +15,10 @@ import {
import { monitorTelegramProvider } from "../telegram/monitor.js"; import { monitorTelegramProvider } from "../telegram/monitor.js";
import { probeTelegram } from "../telegram/probe.js"; import { probeTelegram } from "../telegram/probe.js";
import { resolveTelegramToken } from "../telegram/token.js"; import { resolveTelegramToken } from "../telegram/token.js";
import {
listEnabledWhatsAppAccounts,
resolveDefaultWhatsAppAccountId,
} from "../web/accounts.js";
import type { WebProviderStatus } from "../web/auto-reply.js"; import type { WebProviderStatus } from "../web/auto-reply.js";
import { readWebSelfId } from "../web/session.js"; import { readWebSelfId } from "../web/session.js";
import { formatError } from "./server-utils.js"; import { formatError } from "./server-utils.js";
@ -60,6 +64,7 @@ export type IMessageRuntimeStatus = {
export type ProviderRuntimeSnapshot = { export type ProviderRuntimeSnapshot = {
whatsapp: WebProviderStatus; whatsapp: WebProviderStatus;
whatsappAccounts?: Record<string, WebProviderStatus>;
telegram: TelegramRuntimeStatus; telegram: TelegramRuntimeStatus;
discord: DiscordRuntimeStatus; discord: DiscordRuntimeStatus;
slack: SlackRuntimeStatus; slack: SlackRuntimeStatus;
@ -88,8 +93,8 @@ type ProviderManagerOptions = {
export type ProviderManager = { export type ProviderManager = {
getRuntimeSnapshot: () => ProviderRuntimeSnapshot; getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startProviders: () => Promise<void>; startProviders: () => Promise<void>;
startWhatsAppProvider: () => Promise<void>; startWhatsAppProvider: (accountId?: string) => Promise<void>;
stopWhatsAppProvider: () => Promise<void>; stopWhatsAppProvider: (accountId?: string) => Promise<void>;
startTelegramProvider: () => Promise<void>; startTelegramProvider: () => Promise<void>;
stopTelegramProvider: () => Promise<void>; stopTelegramProvider: () => Promise<void>;
startDiscordProvider: () => Promise<void>; startDiscordProvider: () => Promise<void>;
@ -100,7 +105,7 @@ export type ProviderManager = {
stopSignalProvider: () => Promise<void>; stopSignalProvider: () => Promise<void>;
startIMessageProvider: () => Promise<void>; startIMessageProvider: () => Promise<void>;
stopIMessageProvider: () => Promise<void>; stopIMessageProvider: () => Promise<void>;
markWhatsAppLoggedOut: (cleared: boolean) => void; markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
}; };
export function createProviderManager( export function createProviderManager(
@ -122,20 +127,21 @@ export function createProviderManager(
imessageRuntimeEnv, imessageRuntimeEnv,
} = opts; } = opts;
let whatsappAbort: AbortController | null = null; const whatsappAborts = new Map<string, AbortController>();
let telegramAbort: AbortController | null = null; let telegramAbort: AbortController | null = null;
let discordAbort: AbortController | null = null; let discordAbort: AbortController | null = null;
let slackAbort: AbortController | null = null; let slackAbort: AbortController | null = null;
let signalAbort: AbortController | null = null; let signalAbort: AbortController | null = null;
let imessageAbort: AbortController | null = null; let imessageAbort: AbortController | null = null;
let whatsappTask: Promise<unknown> | null = null; const whatsappTasks = new Map<string, Promise<unknown>>();
let telegramTask: Promise<unknown> | null = null; let telegramTask: Promise<unknown> | null = null;
let discordTask: Promise<unknown> | null = null; let discordTask: Promise<unknown> | null = null;
let slackTask: Promise<unknown> | null = null; let slackTask: Promise<unknown> | null = null;
let signalTask: Promise<unknown> | null = null; let signalTask: Promise<unknown> | null = null;
let imessageTask: Promise<unknown> | null = null; let imessageTask: Promise<unknown> | null = null;
let whatsappRuntime: WebProviderStatus = { const whatsappRuntimes = new Map<string, WebProviderStatus>();
const defaultWhatsAppStatus = (): WebProviderStatus => ({
running: false, running: false,
connected: false, connected: false,
reconnectAttempts: 0, reconnectAttempts: 0,
@ -144,7 +150,7 @@ export function createProviderManager(
lastMessageAt: null, lastMessageAt: null,
lastEventAt: null, lastEventAt: null,
lastError: null, lastError: null,
}; });
let telegramRuntime: TelegramRuntimeStatus = { let telegramRuntime: TelegramRuntimeStatus = {
running: false, running: false,
lastStartAt: null, lastStartAt: null,
@ -180,86 +186,134 @@ export function createProviderManager(
dbPath: null, dbPath: null,
}; };
const updateWhatsAppStatus = (next: WebProviderStatus) => { const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => {
whatsappRuntime = next; whatsappRuntimes.set(accountId, next);
}; };
const startWhatsAppProvider = async () => { const startWhatsAppProvider = async (accountId?: string) => {
if (whatsappTask) return;
const cfg = loadConfig(); const cfg = loadConfig();
const enabledAccounts = listEnabledWhatsAppAccounts(cfg);
const targets = accountId
? enabledAccounts.filter((a) => a.accountId === accountId)
: enabledAccounts;
if (targets.length === 0) return;
if (cfg.web?.enabled === false) { if (cfg.web?.enabled === false) {
whatsappRuntime = { for (const account of targets) {
...whatsappRuntime, const current =
running: false, whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus();
connected: false, whatsappRuntimes.set(account.accountId, {
lastError: "disabled", ...current,
}; running: false,
connected: false,
lastError: "disabled",
});
}
logWhatsApp.info("skipping provider start (web.enabled=false)"); logWhatsApp.info("skipping provider start (web.enabled=false)");
return; return;
} }
if (!(await webAuthExists())) {
whatsappRuntime = { await Promise.all(
...whatsappRuntime, targets.map(async (account) => {
running: false, if (whatsappTasks.has(account.accountId)) return;
connected: false, const current =
lastError: "not linked", whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus();
}; if (!(await webAuthExists(account.authDir))) {
logWhatsApp.info("skipping provider start (no linked session)"); whatsappRuntimes.set(account.accountId, {
return; ...current,
} running: false,
const { e164, jid } = readWebSelfId(); connected: false,
const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; lastError: "not linked",
logWhatsApp.info(`starting provider (${identity})`); });
whatsappAbort = new AbortController(); logWhatsApp.info(
whatsappRuntime = { `[${account.accountId}] skipping provider start (no linked session)`,
...whatsappRuntime, );
running: true, return;
connected: false, }
lastError: null,
}; const { e164, jid } = readWebSelfId(account.authDir);
const task = monitorWebProvider( const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
shouldLogVerbose(), logWhatsApp.info(
undefined, `[${account.accountId}] starting provider (${identity})`,
true, );
undefined, const abort = new AbortController();
whatsappRuntimeEnv, whatsappAborts.set(account.accountId, abort);
whatsappAbort.signal, whatsappRuntimes.set(account.accountId, {
{ statusSink: updateWhatsAppStatus }, ...current,
) running: true,
.catch((err) => {
whatsappRuntime = {
...whatsappRuntime,
lastError: formatError(err),
};
logWhatsApp.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false, connected: false,
}; lastError: null,
}); });
whatsappTask = task;
const task = monitorWebProvider(
shouldLogVerbose(),
undefined,
true,
undefined,
whatsappRuntimeEnv,
abort.signal,
{
statusSink: (next) => updateWhatsAppStatus(account.accountId, next),
accountId: account.accountId,
},
)
.catch((err) => {
const latest =
whatsappRuntimes.get(account.accountId) ??
defaultWhatsAppStatus();
whatsappRuntimes.set(account.accountId, {
...latest,
lastError: formatError(err),
});
logWhatsApp.error(
`[${account.accountId}] provider exited: ${formatError(err)}`,
);
})
.finally(() => {
whatsappAborts.delete(account.accountId);
whatsappTasks.delete(account.accountId);
const latest =
whatsappRuntimes.get(account.accountId) ??
defaultWhatsAppStatus();
whatsappRuntimes.set(account.accountId, {
...latest,
running: false,
connected: false,
});
});
whatsappTasks.set(account.accountId, task);
}),
);
}; };
const stopWhatsAppProvider = async () => { const stopWhatsAppProvider = async (accountId?: string) => {
if (!whatsappAbort && !whatsappTask) return; const ids = accountId
whatsappAbort?.abort(); ? [accountId]
try { : Array.from(
await whatsappTask; new Set([...whatsappAborts.keys(), ...whatsappTasks.keys()]),
} catch { );
// ignore await Promise.all(
} ids.map(async (id) => {
whatsappAbort = null; const abort = whatsappAborts.get(id);
whatsappTask = null; const task = whatsappTasks.get(id);
whatsappRuntime = { if (!abort && !task) return;
...whatsappRuntime, abort?.abort();
running: false, try {
connected: false, await task;
}; } catch {
// ignore
}
whatsappAborts.delete(id);
whatsappTasks.delete(id);
const latest = whatsappRuntimes.get(id) ?? defaultWhatsAppStatus();
whatsappRuntimes.set(id, {
...latest,
running: false,
connected: false,
});
}),
);
}; };
const startTelegramProvider = async () => { const startTelegramProvider = async () => {
@ -754,23 +808,38 @@ export function createProviderManager(
await startIMessageProvider(); await startIMessageProvider();
}; };
const markWhatsAppLoggedOut = (cleared: boolean) => { const markWhatsAppLoggedOut = (cleared: boolean, accountId?: string) => {
whatsappRuntime = { const cfg = loadConfig();
...whatsappRuntime, const resolvedId = accountId ?? resolveDefaultWhatsAppAccountId(cfg);
const current = whatsappRuntimes.get(resolvedId) ?? defaultWhatsAppStatus();
whatsappRuntimes.set(resolvedId, {
...current,
running: false, running: false,
connected: false, connected: false,
lastError: cleared ? "logged out" : whatsappRuntime.lastError, lastError: cleared ? "logged out" : current.lastError,
}; });
}; };
const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => ({ const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => {
whatsapp: { ...whatsappRuntime }, const cfg = loadConfig();
telegram: { ...telegramRuntime }, const defaultId = resolveDefaultWhatsAppAccountId(cfg);
discord: { ...discordRuntime }, const whatsapp = whatsappRuntimes.get(defaultId) ?? defaultWhatsAppStatus();
slack: { ...slackRuntime }, const whatsappAccounts = Object.fromEntries(
signal: { ...signalRuntime }, Array.from(whatsappRuntimes.entries()).map(([id, status]) => [
imessage: { ...imessageRuntime }, id,
}); { ...status },
]),
);
return {
whatsapp: { ...whatsapp },
whatsappAccounts,
telegram: { ...telegramRuntime },
discord: { ...discordRuntime },
slack: { ...slackRuntime },
signal: { ...signalRuntime },
imessage: { ...imessageRuntime },
};
};
return { return {
getRuntimeSnapshot, getRuntimeSnapshot,

View File

@ -33,7 +33,7 @@ describe("gateway server agent", () => {
main: { main: {
sessionId: "sess-main-stale", sessionId: "sess-main-stale",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
}, },
@ -49,7 +49,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
channel: "last", provider: "last",
deliver: true, deliver: true,
idempotencyKey: "idem-agent-last-stale", idempotencyKey: "idem-agent-last-stale",
}); });
@ -76,7 +76,7 @@ describe("gateway server agent", () => {
main: { main: {
sessionId: "sess-main-whatsapp", sessionId: "sess-main-whatsapp",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
}, },
@ -92,7 +92,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
channel: "last", provider: "last",
deliver: true, deliver: true,
idempotencyKey: "idem-agent-last-whatsapp", idempotencyKey: "idem-agent-last-whatsapp",
}); });
@ -120,7 +120,7 @@ describe("gateway server agent", () => {
main: { main: {
sessionId: "sess-main", sessionId: "sess-main",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "telegram", lastProvider: "telegram",
lastTo: "123", lastTo: "123",
}, },
}, },
@ -136,7 +136,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
channel: "last", provider: "last",
deliver: true, deliver: true,
idempotencyKey: "idem-agent-last", idempotencyKey: "idem-agent-last",
}); });
@ -164,7 +164,7 @@ describe("gateway server agent", () => {
main: { main: {
sessionId: "sess-discord", sessionId: "sess-discord",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "discord", lastProvider: "discord",
lastTo: "channel:discord-123", lastTo: "channel:discord-123",
}, },
}, },
@ -180,7 +180,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
channel: "last", provider: "last",
deliver: true, deliver: true,
idempotencyKey: "idem-agent-last-discord", idempotencyKey: "idem-agent-last-discord",
}); });
@ -208,7 +208,7 @@ describe("gateway server agent", () => {
main: { main: {
sessionId: "sess-signal", sessionId: "sess-signal",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "signal", lastProvider: "signal",
lastTo: "+15551234567", lastTo: "+15551234567",
}, },
}, },
@ -224,7 +224,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
channel: "last", provider: "last",
deliver: true, deliver: true,
idempotencyKey: "idem-agent-last-signal", idempotencyKey: "idem-agent-last-signal",
}); });
@ -253,7 +253,7 @@ describe("gateway server agent", () => {
main: { main: {
sessionId: "sess-main-webchat", sessionId: "sess-main-webchat",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "webchat", lastProvider: "webchat",
lastTo: "+1555", lastTo: "+1555",
}, },
}, },
@ -269,7 +269,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", { const res = await rpcReq(ws, "agent", {
message: "hi", message: "hi",
sessionKey: "main", sessionKey: "main",
channel: "last", provider: "last",
deliver: true, deliver: true,
idempotencyKey: "idem-agent-webchat", idempotencyKey: "idem-agent-webchat",
}); });

View File

@ -70,7 +70,7 @@ describe("gateway server chat", () => {
rules: [ rules: [
{ {
action: "deny", action: "deny",
match: { surface: "discord", chatType: "group" }, match: { provider: "discord", chatType: "group" },
}, },
], ],
}, },
@ -84,7 +84,7 @@ describe("gateway server chat", () => {
sessionId: "sess-discord", sessionId: "sess-discord",
updatedAt: Date.now(), updatedAt: Date.now(),
chatType: "group", chatType: "group",
surface: "discord", provider: "discord",
}, },
}, },
null, null,
@ -423,7 +423,7 @@ describe("gateway server chat", () => {
main: { main: {
sessionId: "sess-main", sessionId: "sess-main",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
}, },
@ -446,9 +446,9 @@ describe("gateway server chat", () => {
const stored = JSON.parse( const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath, "utf-8"), await fs.readFile(testState.sessionStorePath, "utf-8"),
) as { ) as {
main?: { lastChannel?: string; lastTo?: string }; main?: { lastProvider?: string; lastTo?: string };
}; };
expect(stored.main?.lastChannel).toBe("whatsapp"); expect(stored.main?.lastProvider).toBe("whatsapp");
expect(stored.main?.lastTo).toBe("+1555"); expect(stored.main?.lastTo).toBe("+1555");
ws.close(); ws.close();

View File

@ -86,7 +86,7 @@ describe("gateway server hooks", () => {
await server.close(); await server.close();
}); });
test("hooks agent rejects invalid channel", async () => { test("hooks agent rejects invalid provider", async () => {
testState.hooksConfig = { enabled: true, token: "hook-secret" }; testState.hooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort(); const port = await getFreePort();
const server = await startGatewayServer(port); const server = await startGatewayServer(port);
@ -96,7 +96,7 @@ describe("gateway server hooks", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer hook-secret", Authorization: "Bearer hook-secret",
}, },
body: JSON.stringify({ message: "Nope", channel: "sms" }), body: JSON.stringify({ message: "Nope", provider: "sms" }),
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(peekSystemEvents().length).toBe(0); expect(peekSystemEvents().length).toBe(0);

View File

@ -732,7 +732,7 @@ describe("gateway server node/bridge", () => {
main: { main: {
sessionId: "sess-main", sessionId: "sess-main",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
}, },
@ -759,7 +759,7 @@ describe("gateway server node/bridge", () => {
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionId).toBe("sess-main"); expect(call.sessionId).toBe("sess-main");
expect(call.deliver).toBe(false); expect(call.deliver).toBe(false);
expect(call.surface).toBe("Node"); expect(call.messageProvider).toBe("node");
const stored = JSON.parse( const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath, "utf-8"), await fs.readFile(testState.sessionStorePath, "utf-8"),

View File

@ -40,23 +40,26 @@ describe("gateway server sessions", () => {
storePath, storePath,
JSON.stringify( JSON.stringify(
{ {
main: { "agent:main:main": {
sessionId: "sess-main", sessionId: "sess-main",
updatedAt: now - 30_000, updatedAt: now - 30_000,
inputTokens: 10, inputTokens: 10,
outputTokens: 20, outputTokens: 20,
thinkingLevel: "low", thinkingLevel: "low",
verboseLevel: "on", verboseLevel: "on",
lastProvider: "whatsapp",
lastTo: "+1555",
lastAccountId: "work",
}, },
"discord:group:dev": { "agent:main:discord:group:dev": {
sessionId: "sess-group", sessionId: "sess-group",
updatedAt: now - 120_000, updatedAt: now - 120_000,
totalTokens: 50, totalTokens: 50,
}, },
"subagent:one": { "agent:main:subagent:one": {
sessionId: "sess-subagent", sessionId: "sess-subagent",
updatedAt: now - 120_000, updatedAt: now - 120_000,
spawnedBy: "main", spawnedBy: "agent:main:main",
}, },
global: { global: {
sessionId: "sess-global", sessionId: "sess-global",
@ -91,16 +94,20 @@ describe("gateway server sessions", () => {
totalTokens?: number; totalTokens?: number;
thinkingLevel?: string; thinkingLevel?: string;
verboseLevel?: string; verboseLevel?: string;
lastAccountId?: string;
}>; }>;
}>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false }); }>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false });
expect(list1.ok).toBe(true); expect(list1.ok).toBe(true);
expect(list1.payload?.path).toBe(storePath); expect(list1.payload?.path).toBe(storePath);
expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false); expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false);
const main = list1.payload?.sessions.find((s) => s.key === "main"); const main = list1.payload?.sessions.find(
(s) => s.key === "agent:main:main",
);
expect(main?.totalTokens).toBe(30); expect(main?.totalTokens).toBe(30);
expect(main?.thinkingLevel).toBe("low"); expect(main?.thinkingLevel).toBe("low");
expect(main?.verboseLevel).toBe("on"); expect(main?.verboseLevel).toBe("on");
expect(main?.lastAccountId).toBe("work");
const active = await rpcReq<{ const active = await rpcReq<{
sessions: Array<{ key: string }>; sessions: Array<{ key: string }>;
@ -110,7 +117,9 @@ describe("gateway server sessions", () => {
activeMinutes: 1, activeMinutes: 1,
}); });
expect(active.ok).toBe(true); expect(active.ok).toBe(true);
expect(active.payload?.sessions.map((s) => s.key)).toEqual(["main"]); expect(active.payload?.sessions.map((s) => s.key)).toEqual([
"agent:main:main",
]);
const limited = await rpcReq<{ const limited = await rpcReq<{
sessions: Array<{ key: string }>; sessions: Array<{ key: string }>;
@ -126,16 +135,16 @@ describe("gateway server sessions", () => {
const patched = await rpcReq<{ ok: true; key: string }>( const patched = await rpcReq<{ ok: true; key: string }>(
ws, ws,
"sessions.patch", "sessions.patch",
{ key: "main", thinkingLevel: "medium", verboseLevel: null }, { key: "agent:main:main", thinkingLevel: "medium", verboseLevel: null },
); );
expect(patched.ok).toBe(true); expect(patched.ok).toBe(true);
expect(patched.payload?.ok).toBe(true); expect(patched.payload?.ok).toBe(true);
expect(patched.payload?.key).toBe("main"); expect(patched.payload?.key).toBe("agent:main:main");
const sendPolicyPatched = await rpcReq<{ const sendPolicyPatched = await rpcReq<{
ok: true; ok: true;
entry: { sendPolicy?: string }; entry: { sendPolicy?: string };
}>(ws, "sessions.patch", { key: "main", sendPolicy: "deny" }); }>(ws, "sessions.patch", { key: "agent:main:main", sendPolicy: "deny" });
expect(sendPolicyPatched.ok).toBe(true); expect(sendPolicyPatched.ok).toBe(true);
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny"); expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
@ -148,7 +157,9 @@ describe("gateway server sessions", () => {
}>; }>;
}>(ws, "sessions.list", {}); }>(ws, "sessions.list", {});
expect(list2.ok).toBe(true); expect(list2.ok).toBe(true);
const main2 = list2.payload?.sessions.find((s) => s.key === "main"); const main2 = list2.payload?.sessions.find(
(s) => s.key === "agent:main:main",
);
expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.thinkingLevel).toBe("medium");
expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.verboseLevel).toBeUndefined();
expect(main2?.sendPolicy).toBe("deny"); expect(main2?.sendPolicy).toBe("deny");
@ -158,23 +169,26 @@ describe("gateway server sessions", () => {
}>(ws, "sessions.list", { }>(ws, "sessions.list", {
includeGlobal: true, includeGlobal: true,
includeUnknown: true, includeUnknown: true,
spawnedBy: "main", spawnedBy: "agent:main:main",
}); });
expect(spawnedOnly.ok).toBe(true); expect(spawnedOnly.ok).toBe(true);
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([ expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([
"subagent:one", "agent:main:subagent:one",
]); ]);
const spawnedPatched = await rpcReq<{ const spawnedPatched = await rpcReq<{
ok: true; ok: true;
entry: { spawnedBy?: string }; entry: { spawnedBy?: string };
}>(ws, "sessions.patch", { key: "subagent:two", spawnedBy: "main" }); }>(ws, "sessions.patch", {
key: "agent:main:subagent:two",
spawnedBy: "agent:main:main",
});
expect(spawnedPatched.ok).toBe(true); expect(spawnedPatched.ok).toBe(true);
expect(spawnedPatched.payload?.entry.spawnedBy).toBe("main"); expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", { const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", {
key: "main", key: "agent:main:main",
spawnedBy: "main", spawnedBy: "agent:main:main",
}); });
expect(spawnedPatchedInvalidKey.ok).toBe(false); expect(spawnedPatchedInvalidKey.ok).toBe(false);
@ -183,7 +197,10 @@ describe("gateway server sessions", () => {
const modelPatched = await rpcReq<{ const modelPatched = await rpcReq<{
ok: true; ok: true;
entry: { modelOverride?: string; providerOverride?: string }; entry: { modelOverride?: string; providerOverride?: string };
}>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" }); }>(ws, "sessions.patch", {
key: "agent:main:main",
model: "openai/gpt-test-a",
});
expect(modelPatched.ok).toBe(true); expect(modelPatched.ok).toBe(true);
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
@ -191,7 +208,7 @@ describe("gateway server sessions", () => {
const compacted = await rpcReq<{ ok: true; compacted: boolean }>( const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
ws, ws,
"sessions.compact", "sessions.compact",
{ key: "main", maxLines: 3 }, { key: "agent:main:main", maxLines: 3 },
); );
expect(compacted.ok).toBe(true); expect(compacted.ok).toBe(true);
expect(compacted.payload?.compacted).toBe(true); expect(compacted.payload?.compacted).toBe(true);
@ -209,7 +226,7 @@ describe("gateway server sessions", () => {
const deleted = await rpcReq<{ ok: true; deleted: boolean }>( const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
ws, ws,
"sessions.delete", "sessions.delete",
{ key: "discord:group:dev" }, { key: "agent:main:discord:group:dev" },
); );
expect(deleted.ok).toBe(true); expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true); expect(deleted.payload?.deleted).toBe(true);
@ -219,7 +236,7 @@ describe("gateway server sessions", () => {
expect(listAfterDelete.ok).toBe(true); expect(listAfterDelete.ok).toBe(true);
expect( expect(
listAfterDelete.payload?.sessions.some( listAfterDelete.payload?.sessions.some(
(s) => s.key === "discord:group:dev", (s) => s.key === "agent:main:discord:group:dev",
), ),
).toBe(false); ).toBe(false);
const filesAfterDelete = await fs.readdir(dir); const filesAfterDelete = await fs.readdir(dir);
@ -231,13 +248,13 @@ describe("gateway server sessions", () => {
ok: true; ok: true;
key: string; key: string;
entry: { sessionId: string }; entry: { sessionId: string };
}>(ws, "sessions.reset", { key: "main" }); }>(ws, "sessions.reset", { key: "agent:main:main" });
expect(reset.ok).toBe(true); expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("main"); expect(reset.payload?.key).toBe("agent:main:main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
const badThinking = await rpcReq(ws, "sessions.patch", { const badThinking = await rpcReq(ws, "sessions.patch", {
key: "main", key: "agent:main:main",
thinkingLevel: "banana", thinkingLevel: "banana",
}); });
expect(badThinking.ok).toBe(false); expect(badThinking.ok).toBe(false);

View File

@ -482,7 +482,7 @@ export async function startGatewayServer(
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
channel: provider:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
@ -514,7 +514,7 @@ export async function startGatewayServer(
thinking: value.thinking, thinking: value.thinking,
timeoutSeconds: value.timeoutSeconds, timeoutSeconds: value.timeoutSeconds,
deliver: value.deliver, deliver: value.deliver,
channel: value.channel, provider: value.provider,
to: value.to, to: value.to,
}, },
state: { nextRunAtMs: now }, state: { nextRunAtMs: now },

View File

@ -15,7 +15,7 @@ describe("gateway session utils", () => {
test("parseGroupKey handles group prefixes", () => { test("parseGroupKey handles group prefixes", () => {
expect(parseGroupKey("group:abc")).toEqual({ id: "abc" }); expect(parseGroupKey("group:abc")).toEqual({ id: "abc" });
expect(parseGroupKey("discord:group:dev")).toEqual({ expect(parseGroupKey("discord:group:dev")).toEqual({
surface: "discord", provider: "discord",
kind: "group", kind: "group",
id: "dev", id: "dev",
}); });

View File

@ -9,12 +9,19 @@ import {
} from "../agents/defaults.js"; } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { import {
buildGroupDisplayName, buildGroupDisplayName,
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionTranscriptPath,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
} from "../config/sessions.js"; } from "../config/sessions.js";
import {
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
export type GatewaySessionsDefaults = { export type GatewaySessionsDefaults = {
model: string | null; model: string | null;
@ -25,7 +32,7 @@ export type GatewaySessionRow = {
key: string; key: string;
kind: "direct" | "group" | "global" | "unknown"; kind: "direct" | "group" | "global" | "unknown";
displayName?: string; displayName?: string;
surface?: string; provider?: string;
subject?: string; subject?: string;
room?: string; room?: string;
space?: string; space?: string;
@ -43,8 +50,9 @@ export type GatewaySessionRow = {
totalTokens?: number; totalTokens?: number;
model?: string; model?: string;
contextTokens?: number; contextTokens?: number;
lastChannel?: SessionEntry["lastChannel"]; lastProvider?: SessionEntry["lastProvider"];
lastTo?: string; lastTo?: string;
lastAccountId?: string;
}; };
export type SessionsListResult = { export type SessionsListResult = {
@ -90,12 +98,16 @@ export function readSessionMessages(
export function resolveSessionTranscriptCandidates( export function resolveSessionTranscriptCandidates(
sessionId: string, sessionId: string,
storePath: string | undefined, storePath: string | undefined,
agentId?: string,
): string[] { ): string[] {
const candidates: string[] = []; const candidates: string[] = [];
if (storePath) { if (storePath) {
const dir = path.dirname(storePath); const dir = path.dirname(storePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`)); candidates.push(path.join(dir, `${sessionId}.jsonl`));
} }
if (agentId) {
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
}
candidates.push( candidates.push(
path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`), path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`),
); );
@ -136,11 +148,12 @@ export function capArrayByJsonBytes<T>(
export function loadSessionEntry(sessionKey: string) { export function loadSessionEntry(sessionKey: string) {
const cfg = loadConfig(); const cfg = loadConfig();
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const storePath = sessionCfg?.store const agentId = resolveAgentIdFromSessionKey(sessionKey);
? resolveStorePath(sessionCfg.store) const storePath = resolveStorePath(sessionCfg?.store, { agentId });
: resolveStorePath(undefined);
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const entry = store[sessionKey]; const parsed = parseAgentSessionKey(sessionKey);
const legacyKey = parsed?.rest;
const entry = store[sessionKey] ?? (legacyKey ? store[legacyKey] : undefined);
return { cfg, storePath, store, entry }; return { cfg, storePath, store, entry };
} }
@ -163,22 +176,167 @@ export function classifySessionKey(
export function parseGroupKey( export function parseGroupKey(
key: string, key: string,
): { surface?: string; kind?: "group" | "channel"; id?: string } | null { ): { provider?: string; kind?: "group" | "channel"; id?: string } | null {
if (key.startsWith("group:")) { const agentParsed = parseAgentSessionKey(key);
const raw = key.slice("group:".length); const rawKey = agentParsed?.rest ?? key;
if (rawKey.startsWith("group:")) {
const raw = rawKey.slice("group:".length);
return raw ? { id: raw } : null; return raw ? { id: raw } : null;
} }
const parts = key.split(":").filter(Boolean); const parts = rawKey.split(":").filter(Boolean);
if (parts.length >= 3) { if (parts.length >= 3) {
const [surface, kind, ...rest] = parts; const [provider, kind, ...rest] = parts;
if (kind === "group" || kind === "channel") { if (kind === "group" || kind === "channel") {
const id = rest.join(":"); const id = rest.join(":");
return { surface, kind, id }; return { provider, kind, id };
} }
} }
return null; return null;
} }
function isStorePathTemplate(store?: string): boolean {
return typeof store === "string" && store.includes("{agentId}");
}
function listExistingAgentIdsFromDisk(): string[] {
const root = resolveStateDir();
const agentsDir = path.join(root, "agents");
try {
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => normalizeAgentId(entry.name))
.filter(Boolean);
} catch {
return [];
}
}
function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
const ids = new Set<string>();
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
ids.add(defaultId);
const agents = cfg.routing?.agents;
if (agents && typeof agents === "object") {
for (const id of Object.keys(agents)) ids.add(normalizeAgentId(id));
}
for (const id of listExistingAgentIdsFromDisk()) ids.add(id);
const sorted = Array.from(ids).filter(Boolean);
sorted.sort((a, b) => a.localeCompare(b));
if (sorted.includes(defaultId)) {
return [defaultId, ...sorted.filter((id) => id !== defaultId)];
}
return sorted;
}
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") return key;
if (key.startsWith("agent:")) return key;
return `agent:${normalizeAgentId(agentId)}:${key}`;
}
function canonicalizeSpawnedByForAgent(
agentId: string,
spawnedBy?: string,
): string | undefined {
const raw = spawnedBy?.trim();
if (!raw) return undefined;
if (raw === "global" || raw === "unknown") return raw;
if (raw.startsWith("agent:")) return raw;
return `agent:${normalizeAgentId(agentId)}:${raw}`;
}
export function resolveGatewaySessionStoreTarget(params: {
cfg: ClawdbotConfig;
key: string;
}): {
agentId: string;
storePath: string;
canonicalKey: string;
storeKeys: string[];
} {
const key = params.key.trim();
const agentId = resolveAgentIdFromSessionKey(key);
const storeConfig = params.cfg.session?.store;
const storePath = resolveStorePath(storeConfig, { agentId });
if (key === "global" || key === "unknown") {
return { agentId, storePath, canonicalKey: key, storeKeys: [key] };
}
const parsed = parseAgentSessionKey(key);
if (parsed) {
return {
agentId,
storePath,
canonicalKey: key,
storeKeys: [key, parsed.rest],
};
}
if (key.startsWith("subagent:")) {
const canonical = canonicalizeSessionKeyForAgent(agentId, key);
return {
agentId,
storePath,
canonicalKey: canonical,
storeKeys: [canonical, key],
};
}
const canonical = canonicalizeSessionKeyForAgent(agentId, key);
return {
agentId,
storePath,
canonicalKey: canonical,
storeKeys: [canonical, key],
};
}
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
storePath: string;
store: Record<string, SessionEntry>;
} {
const storeConfig = cfg.session?.store;
if (storeConfig && !isStorePathTemplate(storeConfig)) {
const storePath = resolveStorePath(storeConfig);
const defaultAgentId = normalizeAgentId(cfg.routing?.defaultAgentId);
const store = loadSessionStore(storePath);
const combined: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
combined[canonicalKey] = {
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(
defaultAgentId,
entry.spawnedBy,
),
};
}
return { storePath, store: combined };
}
const agentIds = listConfiguredAgentIds(cfg);
const combined: Record<string, SessionEntry> = {};
for (const agentId of agentIds) {
const storePath = resolveStorePath(storeConfig, { agentId });
const store = loadSessionStore(storePath);
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
combined[canonicalKey] = {
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy),
};
}
}
const storePath =
typeof storeConfig === "string" && storeConfig.trim()
? storeConfig.trim()
: "(multiple)";
return { storePath, store: combined };
}
export function getSessionDefaults( export function getSessionDefaults(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
): GatewaySessionsDefaults { ): GatewaySessionsDefaults {
@ -251,16 +409,16 @@ export function listSessionsFromStore(params: {
const output = entry?.outputTokens ?? 0; const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output; const total = entry?.totalTokens ?? input + output;
const parsed = parseGroupKey(key); const parsed = parseGroupKey(key);
const surface = entry?.surface ?? parsed?.surface; const provider = entry?.provider ?? parsed?.provider;
const subject = entry?.subject; const subject = entry?.subject;
const room = entry?.room; const room = entry?.room;
const space = entry?.space; const space = entry?.space;
const id = parsed?.id; const id = parsed?.id;
const displayName = const displayName =
entry?.displayName ?? entry?.displayName ??
(surface (provider
? buildGroupDisplayName({ ? buildGroupDisplayName({
surface, provider,
subject, subject,
room, room,
space, space,
@ -272,7 +430,7 @@ export function listSessionsFromStore(params: {
key, key,
kind: classifySessionKey(key, entry), kind: classifySessionKey(key, entry),
displayName, displayName,
surface, provider,
subject, subject,
room, room,
space, space,
@ -290,8 +448,9 @@ export function listSessionsFromStore(params: {
totalTokens: total, totalTokens: total,
model: entry?.model, model: entry?.model,
contextTokens: entry?.contextTokens, contextTokens: entry?.contextTokens,
lastChannel: entry?.lastChannel, lastProvider: entry?.lastProvider,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,
lastAccountId: entry?.lastAccountId,
} satisfies GatewaySessionRow; } satisfies GatewaySessionRow;
}) })
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));

View File

@ -407,7 +407,7 @@ describe("monitorIMessageProvider", () => {
expect(updateLastRouteMock).toHaveBeenCalledWith( expect(updateLastRouteMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
channel: "imessage", provider: "imessage",
to: "chat_id:7", to: "chat_id:7",
}), }),
); );

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