Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/orchestrator/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3501,6 +3501,19 @@ async function waitForOpencodeHealthy(
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}

try {
// Some environments have a broken OpenCode /health probe even while the
// core API surface is already usable. Accept a successful path lookup as
// readiness so session APIs can come up in those runtimes.
unwrap(await client.path.get());
return { healthy: true, degraded: true, reason: lastError ?? undefined };
} catch (error) {
if (!lastError) {
lastError = error instanceof Error ? error.message : String(error);
}
}

await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(lastError ?? "Timed out waiting for OpenCode health");
Expand Down
1 change: 1 addition & 0 deletions examples/microsandbox-openwork-rust/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target/
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { tool } from "@opencode-ai/plugin"

const redactTarget = (value) => {
const text = String(value || '').trim()
if (!text) return ''
if (text.length <= 6) return 'hidden'
return `${text.slice(0, 2)}…${text.slice(-2)}`
}

const buildGuidance = (result) => {
const sent = Number(result?.sent || 0)
const attempted = Number(result?.attempted || 0)
const reason = String(result?.reason || '')
const failures = Array.isArray(result?.failures) ? result.failures : []

if (sent > 0 && failures.length === 0) return 'Delivered successfully.'
if (sent > 0) return 'Delivered to at least one conversation, but some targets failed.'

const chatNotFound = failures.some((item) => /chat not found/i.test(String(item?.error || '')))
if (chatNotFound) {
return 'Delivery failed because the recipient has not started a chat with the bot yet. Ask them to send /start, then retry.'
}

if (/No bound conversations/i.test(reason)) {
return 'No linked conversation found for this workspace yet. Ask the recipient to message the bot first, then retry.'
}

if (attempted === 0) return 'No eligible delivery target found.'
return 'Delivery failed. Retry after confirming the recipient and bot linkage.'
}

export default tool({
description: "Send a message via opencodeRouter (Telegram/Slack) to a peer or directory bindings.",
args: {
text: tool.schema.string().describe("Message text to send"),
channel: tool.schema.enum(["telegram", "slack"]).optional().describe("Channel to send on (default: telegram)"),
identityId: tool.schema.string().optional().describe("OpenCodeRouter identity id (default: all identities)"),
directory: tool.schema.string().optional().describe("Directory to target for fan-out (default: current session directory)"),
peerId: tool.schema.string().optional().describe("Direct destination peer id (chat/thread id)"),
autoBind: tool.schema.boolean().optional().describe("When direct sending, bind peerId to directory if provided"),
},
async execute(args, context) {
const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()
const port = Number(rawPort)
if (!Number.isFinite(port) || port <= 0) {
throw new Error(`Invalid OPENCODE_ROUTER_HEALTH_PORT: ${rawPort}`)
}
const channel = (args.channel || "telegram").trim()
if (channel !== "telegram" && channel !== "slack") {
throw new Error("channel must be telegram or slack")
}
const text = String(args.text || "")
if (!text.trim()) throw new Error("text is required")
const directory = (args.directory || context.directory || "").trim()
const peerId = String(args.peerId || "").trim()
if (!directory && !peerId) throw new Error("Either directory or peerId is required")
const payload = {
channel,
text,
...(args.identityId ? { identityId: String(args.identityId) } : {}),
...(directory ? { directory } : {}),
...(peerId ? { peerId } : {}),
...(args.autoBind === true ? { autoBind: true } : {}),
}
const response = await fetch(`http://127.0.0.1:${port}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const body = await response.text()
let json = null
try {
json = JSON.parse(body)
} catch {
json = null
}
if (!response.ok) {
throw new Error(`opencodeRouter /send failed (${response.status}): ${body}`)
}

const sent = Number(json?.sent || 0)
const attempted = Number(json?.attempted || 0)
const reason = typeof json?.reason === 'string' ? json.reason : ''
const failuresRaw = Array.isArray(json?.failures) ? json.failures : []
const failures = failuresRaw.map((item) => ({
identityId: String(item?.identityId || ''),
error: String(item?.error || 'delivery failed'),
...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),
}))

const result = {
ok: true,
channel,
sent,
attempted,
guidance: buildGuidance({ sent, attempted, reason, failures }),
...(reason ? { reason } : {}),
...(failures.length ? { failures } : {}),
}
return JSON.stringify(result, null, 2)
},
})

Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { tool } from "@opencode-ai/plugin"

const redactTarget = (value) => {
const text = String(value || '').trim()
if (!text) return ''
if (text.length <= 6) return 'hidden'
return `${text.slice(0, 2)}…${text.slice(-2)}`
}

const isNumericTelegramPeerId = (value) => /^-?\d+$/.test(String(value || '').trim())

export default tool({
description: "Check opencodeRouter messaging readiness (health, identities, bindings).",
args: {
channel: tool.schema.enum(["telegram", "slack"]).optional().describe("Channel to inspect (default: telegram)"),
identityId: tool.schema.string().optional().describe("Identity id to scope checks"),
directory: tool.schema.string().optional().describe("Directory to inspect bindings for (default: current session directory)"),
peerId: tool.schema.string().optional().describe("Peer id to inspect bindings for"),
includeBindings: tool.schema.boolean().optional().describe("Include binding details (default: false)"),
},
async execute(args, context) {
const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()
const port = Number(rawPort)
if (!Number.isFinite(port) || port <= 0) {
throw new Error(`Invalid OPENCODE_ROUTER_HEALTH_PORT: ${rawPort}`)
}
const channel = (args.channel || "telegram").trim()
if (channel !== "telegram" && channel !== "slack") {
throw new Error("channel must be telegram or slack")
}
const identityId = String(args.identityId || "").trim()
const directory = (args.directory || context.directory || "").trim()
const peerId = String(args.peerId || "").trim()
const targetValid = channel !== 'telegram' || !peerId || isNumericTelegramPeerId(peerId)
const includeBindings = args.includeBindings === true

const fetchJson = async (path) => {
const response = await fetch(`http://127.0.0.1:${port}${path}`)
const body = await response.text()
let json = null
try {
json = JSON.parse(body)
} catch {
json = null
}
if (!response.ok) {
return { ok: false, status: response.status, json, error: typeof json?.error === "string" ? json.error : body }
}
return { ok: true, status: response.status, json }
}

const health = await fetchJson('/health')
const identities = await fetchJson(`/identities/${channel}`)
let bindings = null
if (includeBindings) {
const search = new URLSearchParams()
search.set('channel', channel)
if (identityId) search.set('identityId', identityId)
bindings = await fetchJson(`/bindings?${search.toString()}`)
}

const identityItems = Array.isArray(identities?.json?.items) ? identities.json.items : []
const scopedIdentityItems = identityId
? identityItems.filter((item) => String(item?.id || '').trim() === identityId)
: identityItems
const runningItems = scopedIdentityItems.filter((item) => item && item.enabled === true && item.running === true)
const enabledItems = scopedIdentityItems.filter((item) => item && item.enabled === true)

const bindingItems = Array.isArray(bindings?.json?.items) ? bindings.json.items : []
const filteredBindings = bindingItems.filter((item) => {
if (!item || typeof item !== 'object') return false
if (directory && String(item.directory || '').trim() !== directory) return false
if (peerId && String(item.peerId || '').trim() !== peerId) return false
return true
})
const publicBindings = filteredBindings.map((item) => ({
channel: String(item.channel || channel),
identityId: String(item.identityId || ''),
directory: String(item.directory || ''),
...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),
updatedAt: item?.updatedAt,
}))

let ready = false
let guidance = ''
let nextAction = ''
if (!health.ok) {
guidance = 'OpenCode Router health endpoint is unavailable'
nextAction = 'check_router_health'
} else if (!identities.ok) {
guidance = `Identity lookup failed for ${channel}`
nextAction = 'check_identity_config'
} else if (runningItems.length === 0) {
guidance = `No running ${channel} identity`
nextAction = 'start_identity'
} else if (!targetValid) {
guidance = 'Telegram direct targets must be numeric chat IDs. Prefer linked conversations over asking users for raw IDs.'
nextAction = 'use_linked_conversation'
} else if (peerId) {
ready = true
guidance = 'Ready for direct send'
nextAction = 'send_direct'
} else if (directory) {
ready = filteredBindings.length > 0
guidance = ready
? 'Ready for directory fan-out send'
: channel === 'telegram'
? 'No linked Telegram conversations yet. Ask the recipient to message your bot (for example /start), then retry.'
: 'No linked conversations found for this directory yet'
nextAction = ready ? 'send_directory' : channel === 'telegram' ? 'wait_for_recipient_start' : 'link_conversation'
} else {
ready = true
guidance = 'Ready. Provide a message target (peer or directory).'
nextAction = 'choose_target'
}

const result = {
ok: health.ok && identities.ok && (!bindings || bindings.ok),
ready,
guidance,
nextAction,
channel,
...(identityId ? { identityId } : {}),
...(directory ? { directory } : {}),
...(peerId ? { targetProvided: true } : {}),
...(peerId ? { targetValid } : {}),
health: {
ok: health.ok,
status: health.status,
error: health.ok ? undefined : health.error,
snapshot: health.ok ? health.json : undefined,
},
identities: {
ok: identities.ok,
status: identities.status,
error: identities.ok ? undefined : identities.error,
configured: scopedIdentityItems.length,
enabled: enabledItems.length,
running: runningItems.length,
items: scopedIdentityItems,
},
...(includeBindings
? {
bindings: {
ok: Boolean(bindings?.ok),
status: bindings?.status,
error: bindings?.ok ? undefined : bindings?.error,
count: filteredBindings.length,
items: publicBindings,
},
}
: {}),
}
return JSON.stringify(result, null, 2)
},
})

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}
Loading
Loading