feat(owpenbot): scope workspace agent and split identities advanced tab

This commit is contained in:
Benjamin Shafii
2026-02-11 15:21:46 -08:00
parent 8774d91b75
commit 0ef02a3eb5
7 changed files with 523 additions and 83 deletions

View File

@@ -214,6 +214,12 @@ export type OpenworkOwpenbotHealthSnapshot = {
lastOutboundAt?: number;
lastMessageAt?: number;
};
agent?: {
scope: "workspace";
path: string;
loaded: boolean;
selected?: string;
};
};
export type OpenworkOwpenbotBindingItem = {
@@ -238,6 +244,7 @@ export type OpenworkOwpenbotSendResult = {
channel: string;
identityId?: string;
directory: string;
peerId?: string;
attempted: number;
sent: number;
failures?: Array<{ identityId: string; peerId: string; error: string }>;
@@ -1011,7 +1018,14 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
),
sendOwpenbotMessage: (
workspaceId: string,
input: { channel: "telegram" | "slack"; text: string; identityId?: string; directory?: string },
input: {
channel: "telegram" | "slack";
text: string;
identityId?: string;
directory?: string;
peerId?: string;
autoBind?: boolean;
},
options?: { healthPort?: number | null },
) => {
const payload = {
@@ -1019,6 +1033,8 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
text: input.text,
...(input.identityId?.trim() ? { identityId: input.identityId.trim() } : {}),
...(input.directory?.trim() ? { directory: input.directory.trim() } : {}),
...(input.peerId?.trim() ? { peerId: input.peerId.trim() } : {}),
...(input.autoBind === true ? { autoBind: true } : {}),
healthPort: options?.healthPort ?? null,
};

View File

@@ -138,6 +138,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
const [slackError, setSlackError] = createSignal<string | null>(null);
const [expandedChannel, setExpandedChannel] = createSignal<string | null>(null);
const [activeTab, setActiveTab] = createSignal<"general" | "advanced">("general");
const [agentLoading, setAgentLoading] = createSignal(false);
const [agentSaving, setAgentSaving] = createSignal(false);
@@ -150,6 +151,8 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
const [sendChannel, setSendChannel] = createSignal<"telegram" | "slack">("telegram");
const [sendDirectory, setSendDirectory] = createSignal("");
const [sendPeerId, setSendPeerId] = createSignal("");
const [sendAutoBind, setSendAutoBind] = createSignal(true);
const [sendText, setSendText] = createSignal("");
const [sendBusy, setSendBusy] = createSignal(false);
const [sendStatus, setSendStatus] = createSignal<string | null>(null);
@@ -228,6 +231,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
return `${days}d ago`;
});
const workspaceAgentStatus = createMemo(() => {
const agent = health()?.agent;
if (!agent) return null;
return {
path: agent.path,
loaded: agent.loaded,
selected: agent.selected ?? "",
};
});
const resetAgentState = () => {
setAgentLoading(false);
setAgentSaving(false);
@@ -353,9 +366,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
channel: sendChannel(),
text,
...(sendDirectory().trim() ? { directory: sendDirectory().trim() } : {}),
...(sendPeerId().trim() ? { peerId: sendPeerId().trim() } : {}),
...(sendAutoBind() ? { autoBind: true } : {}),
});
setSendResult(result);
setSendStatus(`Dispatched ${result.sent}/${result.attempted} messages.`);
const base = `Dispatched ${result.sent}/${result.attempted} messages.`;
setSendStatus(result.reason?.trim() ? `${base} ${result.reason.trim()}` : base);
} catch (error) {
setSendError(formatRequestError(error));
} finally {
@@ -607,6 +623,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
setSendResult(null);
setReconnectStatus(null);
setReconnectError(null);
setActiveTab("general");
});
onMount(() => {
@@ -679,6 +696,31 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
</div>
</Show>
<div class="flex items-center gap-2 rounded-xl border border-gray-4 bg-gray-1 p-1">
<button
class={`flex-1 rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
activeTab() === "general"
? "bg-gray-12 text-gray-1"
: "text-gray-10 hover:bg-gray-2"
}`}
onClick={() => setActiveTab("general")}
>
General
</button>
<button
class={`flex-1 rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
activeTab() === "advanced"
? "bg-gray-12 text-gray-1"
: "text-gray-10 hover:bg-gray-2"
}`}
onClick={() => setActiveTab("advanced")}
>
Advanced
</button>
</div>
<Show when={activeTab() === "general"}>
{/* ---- Worker status card ---- */}
<div class="rounded-xl border border-gray-4 bg-gray-1 p-4 space-y-3.5">
<div class="flex items-center justify-between">
@@ -1101,6 +1143,10 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
</div>
</div>
</Show>
<Show when={activeTab() === "advanced"}>
{/* ---- Message routing ---- */}
<div>
<div class="text-[11px] font-semibold text-gray-9 uppercase tracking-wider mb-2">
@@ -1128,7 +1174,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
</div>
<div class="text-xs text-gray-10 mt-2.5">
Advanced: reply with <code class="text-[11px] font-mono bg-gray-3 px-1 py-0.5 rounded">/dir &lt;path&gt;</code> in Slack/Telegram to override the directory for a specific chat.
Advanced: reply with <code class="text-[11px] font-mono bg-gray-3 px-1 py-0.5 rounded">/dir &lt;path&gt;</code> in Slack/Telegram to override the directory for a specific chat (limited to this workspace root).
</div>
</div>
@@ -1138,7 +1184,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
<div>
<div class="text-[13px] font-semibold text-gray-12">Messaging agent behavior</div>
<div class="text-[12px] text-gray-9 mt-0.5">
Edit the workspace instructions used before each inbound Telegram/Slack message.
One file per workspace. Add optional first line <code class="font-mono">@agent &lt;id&gt;</code> to route via a specific OpenCode agent.
</div>
</div>
<span class="rounded-md border border-gray-4 bg-gray-2/50 px-2 py-1 text-[11px] font-mono text-gray-10">
@@ -1146,6 +1192,14 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
</span>
</div>
<Show when={workspaceAgentStatus()}>
{(value) => (
<div class="rounded-lg border border-gray-4 bg-gray-2/40 px-3 py-2 text-[11px] text-gray-10">
Active scope: workspace · status: {value().loaded ? "loaded" : "missing"} · selected agent: {value().selected || "(none)"}
</div>
)}
</Show>
<Show when={agentLoading()}>
<div class="text-[11px] text-gray-9">Loading agent file</div>
</Show>
@@ -1208,7 +1262,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
<div>
<div class="text-[13px] font-semibold text-gray-12">Send test message</div>
<div class="text-[12px] text-gray-9 mt-0.5">
Dispatch an outbound message via the workspace send route to validate bindings and channel wiring.
Validate outbound wiring. Use a peer ID for direct send, or leave peer ID empty to fan out by bindings in a directory.
</div>
</div>
@@ -1224,6 +1278,18 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
<option value="slack">Slack</option>
</select>
</div>
<div>
<label class="text-[12px] text-gray-9 block mb-1">Peer ID (optional)</label>
<input
class="w-full rounded-lg border border-gray-4 bg-gray-1 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-8"
placeholder={sendChannel() === "telegram" ? "Telegram chat id (e.g. 123456789)" : "Slack peer id (e.g. D12345678|thread_ts)"}
value={sendPeerId()}
onInput={(e) => setSendPeerId(e.currentTarget.value)}
/>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div>
<label class="text-[12px] text-gray-9 block mb-1">Directory (optional)</label>
<input
@@ -1233,6 +1299,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
onInput={(e) => setSendDirectory(e.currentTarget.value)}
/>
</div>
<div class="flex items-end pb-1">
<label class="flex items-center gap-2 text-xs text-gray-11">
<input
type="checkbox"
checked={sendAutoBind()}
onChange={(e) => setSendAutoBind(e.currentTarget.checked)}
/>
Auto-bind peer to directory on direct send
</label>
</div>
</div>
<div>
@@ -1277,6 +1353,8 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
</Show>
</div>
</Show>
</Show>
</div>
);

View File

@@ -1,7 +1,7 @@
import { setTimeout as delay } from "node:timers/promises";
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import type { Logger } from "pino";
@@ -139,6 +139,13 @@ const TYPING_INTERVAL_MS = 6000;
const OWPENBOT_AGENT_FILE_RELATIVE_PATH = ".opencode/agents/owpenbot.md";
const OWPENBOT_AGENT_MAX_CHARS = 16_000;
type MessagingAgentConfig = {
filePath: string;
loaded: boolean;
selectedAgent?: string;
instructions: string;
};
// Model presets for quick switching
const MODEL_PRESETS: Record<string, ModelRef> = {
opus: { providerID: "anthropic", modelID: "claude-opus-4-5-20251101" },
@@ -182,7 +189,83 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
const reportStatus = reporter?.onStatus;
const clients = new Map<string, ReturnType<typeof createClient>>();
const defaultDirectory = config.opencodeDirectory;
const agentPromptCache = new Map<string, { mtimeMs: number; content: string }>();
const workspaceRoot = resolve(defaultDirectory || process.cwd());
const workspaceAgentFilePath = join(workspaceRoot, OWPENBOT_AGENT_FILE_RELATIVE_PATH);
const agentPromptCache = new Map<string, { mtimeMs: number; config: MessagingAgentConfig }>();
let latestAgentConfig: MessagingAgentConfig = {
filePath: workspaceAgentFilePath,
loaded: false,
instructions: "",
};
const parseMessagingAgentFile = (content: string): { selectedAgent?: string; instructions: string } => {
const lines = content.split(/\r?\n/);
let start = 0;
while (start < lines.length && !lines[start]?.trim()) {
start += 1;
}
let selectedAgent: string | undefined;
if (start < lines.length) {
const first = lines[start]?.trim() ?? "";
const match = first.match(/^@agent\s+([A-Za-z0-9_.:/-]+)$/);
if (match?.[1]) {
selectedAgent = match[1];
lines.splice(start, 1);
}
}
const instructions = lines.join("\n").trim();
return { ...(selectedAgent ? { selectedAgent } : {}), instructions };
};
const loadMessagingAgentConfig = async (): Promise<MessagingAgentConfig> => {
const filePath = workspaceAgentFilePath;
try {
const info = await stat(filePath);
if (!info.isFile()) {
agentPromptCache.delete(filePath);
latestAgentConfig = { filePath, loaded: false, instructions: "" };
return latestAgentConfig;
}
const cached = agentPromptCache.get(filePath);
if (cached && cached.mtimeMs === info.mtimeMs) {
latestAgentConfig = cached.config;
return latestAgentConfig;
}
const raw = (await readFile(filePath, "utf8")).trim();
if (!raw) {
const next: MessagingAgentConfig = { filePath, loaded: false, instructions: "" };
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next });
latestAgentConfig = next;
return next;
}
const truncated = raw.length > OWPENBOT_AGENT_MAX_CHARS ? raw.slice(0, OWPENBOT_AGENT_MAX_CHARS) : raw;
const parsed = parseMessagingAgentFile(truncated);
const next: MessagingAgentConfig = {
filePath,
loaded: Boolean(parsed.instructions || parsed.selectedAgent),
...(parsed.selectedAgent ? { selectedAgent: parsed.selectedAgent } : {}),
instructions: parsed.instructions,
};
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next });
latestAgentConfig = next;
return next;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "ENOENT") {
agentPromptCache.delete(filePath);
latestAgentConfig = { filePath, loaded: false, instructions: "" };
return latestAgentConfig;
}
logger.warn({ error, filePath }, "failed to load owpenbot agent file");
latestAgentConfig = { filePath, loaded: false, instructions: "" };
return latestAgentConfig;
}
};
const isDangerousRootDirectory = (dir: string) => {
const normalized = dir.trim();
@@ -217,46 +300,6 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
return next;
};
const loadMessagingAgentPrompt = async (directory: string): Promise<string> => {
const base = directory.trim() || defaultDirectory;
if (!base) return "";
const filePath = join(base, OWPENBOT_AGENT_FILE_RELATIVE_PATH);
try {
const info = await stat(filePath);
if (!info.isFile()) {
agentPromptCache.delete(filePath);
return "";
}
const cached = agentPromptCache.get(filePath);
if (cached && cached.mtimeMs === info.mtimeMs) {
return cached.content;
}
const content = (await readFile(filePath, "utf8")).trim();
if (!content) {
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, content: "" });
return "";
}
const next =
content.length > OWPENBOT_AGENT_MAX_CHARS
? content.slice(0, OWPENBOT_AGENT_MAX_CHARS)
: content;
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, content: next });
return next;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "ENOENT") {
agentPromptCache.delete(filePath);
return "";
}
logger.warn({ error, filePath }, "failed to load owpenbot agent file");
return "";
}
};
const rootClient = getClient(defaultDirectory);
const store = deps.store ?? new BridgeStore(config.dbPath);
@@ -321,6 +364,31 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
};
const workspaceRootNormalized = normalizeDirectory(workspaceRoot);
const isWithinWorkspaceRoot = (candidate: string) => {
const resolved = resolve(candidate || workspaceRoot);
const relativePath = relative(workspaceRoot, resolved);
if (!relativePath) return true;
if (relativePath === ".") return true;
if (relativePath.startsWith("..") || isAbsolute(relativePath)) return false;
const boundary = workspaceRoot.endsWith(sep) ? workspaceRoot : `${workspaceRoot}${sep}`;
return resolved === workspaceRoot || resolved.startsWith(boundary);
};
const resolveScopedDirectory = (input: string): { ok: true; directory: string } | { ok: false; error: string } => {
const trimmed = input.trim();
if (!trimmed) return { ok: false, error: "Directory is required." };
const resolved = resolve(isAbsolute(trimmed) ? trimmed : join(workspaceRoot, trimmed));
if (!isWithinWorkspaceRoot(resolved)) {
return {
ok: false,
error: `Directory must stay within workspace root: ${workspaceRootNormalized}`,
};
}
return { ok: true, directory: normalizeDirectory(resolved) };
};
const formatModelLabel = (model?: ModelRef) =>
model ? `${model.providerID}/${model.modelID}` : null;
@@ -443,6 +511,8 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
lastOutboundAt = now;
};
await loadMessagingAgentConfig();
let stopHealthServer: (() => void) | null = null;
if (!deps.disableHealthServer && config.healthPort) {
stopHealthServer = startHealthServer(
@@ -473,6 +543,12 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
? { lastMessageAt: Math.max(lastInboundAt ?? 0, lastOutboundAt ?? 0) }
: {}),
},
agent: {
scope: "workspace",
path: latestAgentConfig.filePath,
loaded: latestAgentConfig.loaded,
...(latestAgentConfig.selectedAgent ? { selected: latestAgentConfig.selectedAgent } : {}),
},
}),
logger,
{
@@ -855,7 +931,13 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
if (!peerKey || !directory) {
throw new Error("peerId and directory are required");
}
const normalizedDir = normalizeDirectory(directory);
const scoped = resolveScopedDirectory(directory);
if (!scoped.ok) {
const error = new Error(scoped.error) as Error & { status?: number };
error.status = 400;
throw error;
}
const normalizedDir = scoped.directory;
store.upsertBinding(channel as ChannelName, identityId, peerKey, normalizedDir);
store.deleteSession(channel as ChannelName, identityId, peerKey);
ensureEventSubscription(normalizedDir);
@@ -874,23 +956,107 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
store.deleteSession(channel as ChannelName, identityId, peerKey);
},
sendMessage: async (input: { channel: string; identityId?: string; directory: string; text: string }) => {
sendMessage: async (input: {
channel: string;
identityId?: string;
directory?: string;
peerId?: string;
text: string;
autoBind?: boolean;
}) => {
const channelRaw = input.channel.trim().toLowerCase();
if (channelRaw !== "telegram" && channelRaw !== "slack") {
throw new Error("Invalid channel");
}
const channel = channelRaw as ChannelName;
const identityId = input.identityId?.trim() ? normalizeIdentityId(input.identityId) : undefined;
const directory = input.directory.trim();
const directoryInput = (input.directory ?? "").trim();
const peerId = (input.peerId ?? "").trim();
const autoBind = input.autoBind === true;
const text = input.text ?? "";
if (!directory) {
throw new Error("directory is required");
}
if (!text.trim()) {
throw new Error("text is required");
}
const normalizedDir = normalizeDirectory(directory);
if (!directoryInput && !peerId) {
throw new Error("directory or peerId is required");
}
const normalizedDir = directoryInput ? (() => {
const scoped = resolveScopedDirectory(directoryInput);
if (!scoped.ok) {
const error = new Error(scoped.error) as Error & { status?: number };
error.status = 400;
throw error;
}
return scoped.directory;
})() : "";
const resolveSendIdentityId = () => {
if (identityId) return identityId;
const active = Array.from(adapters.values()).find((adapter) => adapter.name === channel);
return active?.identityId;
};
const targetIdentityId = resolveSendIdentityId();
if (peerId && !targetIdentityId) {
return {
channel,
directory: normalizedDir || workspaceRootNormalized,
peerId,
attempted: 0,
sent: 0,
reason: `No ${channel} adapter is running for direct send`,
};
}
if (peerId && targetIdentityId) {
const adapter = adapters.get(adapterKey(channel, targetIdentityId));
if (!adapter) {
return {
channel,
directory: normalizedDir || workspaceRootNormalized,
identityId: targetIdentityId,
peerId,
attempted: 1,
sent: 0,
failures: [{ identityId: targetIdentityId, peerId, error: "Adapter not running" }],
};
}
if (autoBind && normalizedDir) {
store.upsertBinding(channel, targetIdentityId, peerId, normalizedDir);
store.deleteSession(channel, targetIdentityId, peerId);
ensureEventSubscription(normalizedDir);
}
try {
await sendText(channel, targetIdentityId, peerId, text, { kind: "system", display: false });
return {
channel,
directory: normalizedDir || workspaceRootNormalized,
identityId: targetIdentityId,
peerId,
attempted: 1,
sent: 1,
};
} catch (error) {
return {
channel,
directory: normalizedDir || workspaceRootNormalized,
identityId: targetIdentityId,
peerId,
attempted: 1,
sent: 0,
failures: [{
identityId: targetIdentityId,
peerId,
error: error instanceof Error ? error.message : String(error),
}],
};
}
}
const bindings = store.listBindings({
channel,
...(identityId ? { identityId } : {}),
@@ -1140,11 +1306,11 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId);
const boundDirectory =
const boundDirectoryCandidate =
binding?.directory?.trim() || session?.directory?.trim() || identityDirectory || defaultDirectory;
const hasExplicitBinding = Boolean(binding?.directory?.trim() || session?.directory?.trim() || identityDirectory);
if (!boundDirectory || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectory))) {
if (!boundDirectoryCandidate || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectoryCandidate))) {
await sendText(
inbound.channel,
inbound.identityId,
@@ -1155,6 +1321,13 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
return;
}
const scopedBound = resolveScopedDirectory(boundDirectoryCandidate);
if (!scopedBound.ok) {
await sendText(inbound.channel, inbound.identityId, inbound.peerId, scopedBound.error, { kind: "system" });
return;
}
const boundDirectory = scopedBound.directory;
if (!binding?.directory?.trim()) {
store.upsertBinding(inbound.channel, inbound.identityId, peerKey, boundDirectory);
}
@@ -1200,22 +1373,33 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
startTyping(runState);
try {
const effectiveModel = getUserModel(inbound.channel, inbound.identityId, peerKey, config.model);
const messagingAgentPrompt = await loadMessagingAgentPrompt(boundDirectory);
const promptText = messagingAgentPrompt
const messagingAgent = await loadMessagingAgentConfig();
const promptText = messagingAgent.instructions
? [
"You are handling a Slack/Telegram message via OpenWork.",
"Follow this workspace messaging agent file:",
messagingAgentPrompt,
`Workspace agent file: ${messagingAgent.filePath}`,
...(messagingAgent.selectedAgent ? [`Selected OpenCode agent: ${messagingAgent.selectedAgent}`] : []),
"Follow these workspace messaging instructions:",
messagingAgent.instructions,
"",
"Incoming user message:",
inbound.text,
].join("\n")
: inbound.text;
logger.debug({ sessionID, length: inbound.text.length, model: effectiveModel }, "prompt start");
logger.debug(
{
sessionID,
length: inbound.text.length,
model: effectiveModel,
agent: messagingAgent.selectedAgent,
},
"prompt start",
);
const response = await getClient(boundDirectory).session.prompt({
sessionID,
parts: [{ type: "text", text: promptText }],
...(effectiveModel ? { model: effectiveModel } : {}),
...(messagingAgent.selectedAgent ? { agent: messagingAgent.selectedAgent } : {}),
});
const parts = (response as { parts?: Array<{ type?: string; text?: string; ignored?: boolean }> }).parts ?? [];
const textParts = parts.filter((part) => part.type === "text" && !part.ignored);
@@ -1341,7 +1525,12 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
await sendText(channel, identityId, peerId, `Current directory: ${current || "(none)"}`, { kind: "system" });
return true;
}
const normalized = normalizeDirectory(next);
const scoped = resolveScopedDirectory(next);
if (!scoped.ok) {
await sendText(channel, identityId, peerId, scoped.error, { kind: "system" });
return true;
}
const normalized = scoped.directory;
store.upsertBinding(channel, identityId, peerKey, normalized);
store.deleteSession(channel, identityId, peerKey);
ensureEventSubscription(normalized);
@@ -1350,17 +1539,17 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
}
if (command === "agent") {
const binding = store.getBinding(channel, identityId, peerKey);
const current =
binding?.directory?.trim() || store.getSession(channel, identityId, peerKey)?.directory?.trim() || defaultDirectory;
const resolved = current.trim() || defaultDirectory;
const filePath = join(resolved, OWPENBOT_AGENT_FILE_RELATIVE_PATH);
const loaded = await loadMessagingAgentPrompt(resolved);
const config = await loadMessagingAgentConfig();
await sendText(
channel,
identityId,
peerId,
`Agent file: ${filePath}\nStatus: ${loaded ? "loaded" : "missing or empty"}`,
[
`Scope: workspace`,
`Agent file: ${config.filePath}`,
`OpenCode agent: ${config.selectedAgent ?? "(none)"}`,
`Status: ${config.loaded ? "loaded" : "missing or empty"}`,
].join("\n"),
{ kind: "system" },
);
return true;
@@ -1368,7 +1557,7 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
// /help command
if (command === "help") {
const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir <path> - bind this chat to a directory\n/dir - show current directory\n/agent - show workspace agent file path\n/model - show current\n/reset - start fresh\n/help - this`;
const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir <path> - bind this chat to a workspace directory\n/dir - show current directory\n/agent - show workspace agent scope/path\n/model - show current\n/reset - start fresh\n/help - this`;
await sendText(channel, identityId, peerId, helpText, { kind: "system" });
return true;
}

View File

@@ -25,6 +25,12 @@ export type HealthSnapshot = {
lastOutboundAt?: number;
lastMessageAt?: number;
};
agent?: {
scope: "workspace";
path: string;
loaded: boolean;
selected?: string;
};
};
export type GroupsConfigResult = {
@@ -97,7 +103,9 @@ export type BindingsListResult = {
export type SendMessageInput = {
channel: string;
identityId?: string;
directory: string;
directory?: string;
peerId?: string;
autoBind?: boolean;
text: string;
};
@@ -105,6 +113,7 @@ export type SendMessageResult = {
channel: string;
directory: string;
identityId?: string;
peerId?: string;
attempted: number;
sent: number;
failures?: Array<{ identityId: string; peerId: string; error: string }>;
@@ -582,17 +591,21 @@ export function startHealthServer(
const channel = typeof payload.channel === "string" ? payload.channel.trim() : "";
const identityId = typeof payload.identityId === "string" ? payload.identityId.trim() : "";
const directory = typeof payload.directory === "string" ? payload.directory.trim() : "";
const peerId = typeof payload.peerId === "string" ? payload.peerId.trim() : "";
const autoBind = payload.autoBind === true;
const text = typeof payload.text === "string" ? payload.text : "";
if (!channel || !directory || !text.trim()) {
if (!channel || !text.trim() || (!directory && !peerId)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "channel, directory, and text are required" }));
res.end(JSON.stringify({ ok: false, error: "channel, text, and either directory or peerId are required" }));
return;
}
const result = await handlers.sendMessage({
channel,
...(identityId ? { identityId } : {}),
directory,
...(directory ? { directory } : {}),
...(peerId ? { peerId } : {}),
...(autoBind ? { autoBind: true } : {}),
text,
});
res.writeHead(200, { "Content-Type": "application/json" });

View File

@@ -19,10 +19,10 @@ function createLoggerStub() {
return base;
}
test("bridge: routes sessions per peer directory binding", async () => {
test("bridge: routes sessions per peer directory binding within workspace", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-multiws-"));
const wsA = path.join(dir, "ws-a");
const wsB = path.join(dir, "ws-b");
const wsB = path.join(wsA, "project-b");
fs.mkdirSync(wsA, { recursive: true });
fs.mkdirSync(wsB, { recursive: true });
@@ -98,14 +98,85 @@ test("bridge: routes sessions per peer directory binding", async () => {
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-B", text: `/dir ${wsB}`, raw: {} });
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-B", text: "ping", raw: {} });
// Ensure prompts were routed to different directories
// Ensure prompts were routed to different workspace subdirectories
assert.ok(prompted.includes(wsA));
assert.ok(prompted.includes(wsB));
// Ensure output includes per-directory pong
const output = sent.map((m) => `${m.peerId}:${m.text}`).join("\n");
assert.ok(output.includes("D-A:pong:ws-a"));
assert.ok(output.includes("D-B:pong:ws-b"));
assert.ok(output.includes("D-B:pong:project-b"));
await bridge.stop();
});
test("bridge: rejects /dir outside workspace root", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-multiws-"));
const wsA = path.join(dir, "ws-a");
const outside = path.join(dir, "outside");
fs.mkdirSync(wsA, { recursive: true });
fs.mkdirSync(outside, { recursive: true });
const dbPath = path.join(dir, "owpenbot.db");
const prompted = [];
const sent = [];
const slackAdapter = {
key: "slack:default",
name: "slack",
identityId: "default",
maxTextLength: 39_000,
async start() {},
async stop() {},
async sendText(peerId, text) {
sent.push({ peerId, text });
},
};
const clientFactory = (directory) => ({
global: { health: async () => ({ healthy: true, version: "test" }) },
session: {
create: async () => ({ id: "session-1" }),
prompt: async () => {
prompted.push(directory);
return { parts: [{ type: "text", text: "pong" }] };
},
},
});
const bridge = await startBridge(
{
configPath: path.join(dir, "owpenbot.json"),
configFile: { version: 1 },
opencodeUrl: "http://127.0.0.1:4096",
opencodeDirectory: wsA,
telegramBots: [],
slackApps: [],
dataDir: dir,
dbPath,
logFile: path.join(dir, "owpenbot.log"),
toolUpdatesEnabled: false,
groupsEnabled: false,
permissionMode: "allow",
toolOutputLimit: 1200,
healthPort: undefined,
logLevel: "silent",
},
createLoggerStub(),
undefined,
{
clientFactory,
adapters: new Map([["slack:default", slackAdapter]]),
disableEventStream: true,
disableHealthServer: true,
},
);
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-OUT", text: `/dir ${outside}`, raw: {} });
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-OUT", text: "ping", raw: {} });
assert.ok(sent.some((m) => m.text.includes("Directory must stay within workspace root")));
assert.ok(prompted.every((dirPath) => dirPath === wsA));
await bridge.stop();
});

View File

@@ -100,7 +100,7 @@ test("health /send delivers to directory bindings", async () => {
store.close();
});
test("health /send returns 404 when no bindings exist", async () => {
test("health /send reports no-op when no bindings exist", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-health-send-"));
const dbPath = path.join(dir, "owpenbot.db");
const store = new BridgeStore(dbPath);
@@ -153,3 +153,72 @@ test("health /send returns 404 when no bindings exist", async () => {
await bridge.stop();
store.close();
});
test("health /send can deliver directly with peerId", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-health-send-"));
const dbPath = path.join(dir, "owpenbot.db");
const store = new BridgeStore(dbPath);
const healthPort = await freePort();
const sent = [];
const slackAdapter = {
key: "slack:default",
name: "slack",
identityId: "default",
maxTextLength: 39_000,
async start() {},
async stop() {},
async sendText(peerId, text) {
sent.push({ peerId, text });
},
};
const bridge = await startBridge(
{
configPath: path.join(dir, "owpenbot.json"),
configFile: { version: 1 },
opencodeUrl: "http://127.0.0.1:4096",
opencodeDirectory: dir,
telegramBots: [],
slackApps: [],
dataDir: dir,
dbPath,
logFile: path.join(dir, "owpenbot.log"),
toolUpdatesEnabled: false,
groupsEnabled: false,
permissionMode: "allow",
toolOutputLimit: 1200,
healthPort,
logLevel: "silent",
},
createLoggerStub(),
undefined,
{
client: {
global: {
health: async () => ({ healthy: true, version: "test" }),
},
},
store,
adapters: new Map([["slack:default", slackAdapter]]),
disableEventStream: true,
},
);
const response = await fetch(`http://127.0.0.1:${healthPort}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel: "slack", peerId: "D555", text: "hello-direct" }),
});
assert.equal(response.status, 200);
const json = await response.json();
assert.equal(json.ok, true);
assert.equal(json.peerId, "D555");
assert.equal(json.sent, 1);
assert.equal(sent.length, 1);
assert.equal(sent[0].peerId, "D555");
assert.equal(sent[0].text, "hello-direct");
await bridge.stop();
store.close();
});

View File

@@ -2062,6 +2062,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
const body = await readJsonBody(ctx.request);
const channel = typeof body.channel === "string" ? body.channel.trim().toLowerCase() : "";
const text = typeof body.text === "string" ? body.text : "";
const peerId = typeof body.peerId === "string" ? body.peerId.trim() : "";
const autoBind = body.autoBind === true || body.autoBind === "true";
const directoryInput = typeof body.directory === "string" ? body.directory.trim() : "";
const directory = directoryInput || workspace.path;
const healthPort = normalizeHealthPort(body.healthPort);
@@ -2082,8 +2084,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
if (channel !== "telegram" && channel !== "slack") {
throw new ApiError(400, "invalid_channel", "channel must be 'telegram' or 'slack'");
}
if (!directory.trim()) {
throw new ApiError(400, "directory_required", "directory is required");
if (!directory.trim() && !peerId) {
throw new ApiError(400, "directory_required", "directory is required when peerId is not provided");
}
if (!text.trim()) {
throw new ApiError(400, "text_required", "text is required");
@@ -2095,7 +2097,9 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
{
channel,
identityId,
directory,
...(directory.trim() ? { directory } : {}),
...(peerId ? { peerId } : {}),
...(autoBind ? { autoBind: true } : {}),
text,
},
{ port, requestHost, timeoutMs: 5_000 },
@@ -2115,7 +2119,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
actor: ctx.actor ?? { type: "remote" },
action: "owpenbot.send",
target: `owpenbot.${channel}`,
summary: `Sent outbound ${channel} message for ${identityId}`,
summary: `Sent outbound ${channel} message for ${identityId}${peerId ? ` to ${peerId}` : ""}`,
timestamp: Date.now(),
});