Skip to content
Merged
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
32 changes: 11 additions & 21 deletions packages/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
mod cli;
mod window_customizer;

use cli::{get_sidecar_path, install_cli, sync_cli};
use cli::{install_cli, sync_cli};
use std::{
collections::VecDeque,
net::{SocketAddr, TcpListener},
Expand Down Expand Up @@ -93,10 +93,6 @@ fn get_sidecar_port() -> u32 {
}) as u32
}

fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}

fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
Expand All @@ -119,22 +115,16 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.expect("Failed to spawn opencode");

#[cfg(not(target_os = "windows"))]
let (mut rx, child) = {
let sidecar = get_sidecar_path();
let shell = get_user_shell();
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args([
"-il",
"-c",
&format!("{} serve --port={}", sidecar.display(), port),
])
.spawn()
.expect("Failed to spawn opencode")
};
let (mut rx, child) = app
.shell()
.sidecar("iris-cli")
.expect("Failed to resolve iris-cli sidecar")
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["serve", &format!("--port={port}")])
.spawn()
.expect("Failed to spawn opencode");

tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
Expand Down
29 changes: 23 additions & 6 deletions packages/opencode/src/cli/cmd/platform-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
PLATFORM_URLS,
getBridgeToken, writeJson } from "./iris-api"
import { exec } from "child_process"
import { randomBytes } from "node:crypto"
import { detectNewConnection, extractConnections, type ConnectionRow } from "./integration-connect-state"
import { isLocalOAuthProvider, runLocalOAuthConnect } from "./integration-oauth-connect"
import { PathwaysCommand } from "./platform-integrations-pathways"
Expand Down Expand Up @@ -372,7 +373,16 @@ export async function resolveAccountToIntegrationId(
})
if (candidates.length === 0) return null
return Number(candidates[0].id) || null
} catch {
} catch (err) {
// Distinguish network/transport errors (which should surface) from a
// genuine "no match". Previously swallowed as null, masking outages
// (bug #16).
if (err instanceof TypeError || (err as any)?.name === "FetchError" || (err as any)?.code === "ECONNREFUSED") {
throw err
}
if (process.env.IRIS_DEBUG) {
console.error(dim(`[resolveAccountToIntegrationId] lookup failed for ${normalizedType}/${account}:`), err)
}
return null
}
}
Expand Down Expand Up @@ -949,7 +959,7 @@ const ConnectCommand = cmd({
auth_config: { id: authConfig.id },
connection: {
user_id: `user-${userId}`,
callback_url: `${PLATFORM_URLS.irisApi}/api/v1/integrations-temp/oauth-callback/whatsapp?state=${encodeURIComponent(btoa(JSON.stringify({ type: "whatsapp", provider: "composio", user_id: userId, timestamp: Date.now() })))}`,
callback_url: `${PLATFORM_URLS.irisApi}/api/v1/integrations-temp/oauth-callback/whatsapp?state=${encodeURIComponent(Buffer.from(JSON.stringify({ type: "whatsapp", provider: "composio", user_id: userId, timestamp: Date.now(), nonce: randomBytes(16).toString("hex") }), "utf8").toString("base64"))}`,
state: { authScheme: "OAUTH2", val: { generic_id: String(wabaId) } },
},
}),
Expand Down Expand Up @@ -1450,19 +1460,26 @@ const ExecCommand = cmd({

// No hardcoded fallback: a stale key here silently 401s every integrations
// call (see bug #164644). Require COMPOSIO_API_KEY and fail loud if missing.
const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? ""
// Read at call-time (not module load) so env vars set after import are honored
// (see bug #17 — COMPOSIO_API_KEY read at module load time missed late-set vars).
const COMPOSIO_BASE = "https://backend.composio.dev/api"

async function composioFetch(path: string, init?: RequestInit) {
if (!COMPOSIO_KEY) {
function getComposioKey(): string {
const key = process.env.COMPOSIO_API_KEY ?? ""
if (!key) {
throw new Error(
"COMPOSIO_API_KEY is not set. Generate a key at https://dashboard.composio.dev → API Keys and export it (e.g. `export COMPOSIO_API_KEY=ak_…`) before running `iris integrations …`.",
)
}
return key
}

async function composioFetch(path: string, init?: RequestInit) {
const key = getComposioKey()
return fetch(`${COMPOSIO_BASE}${path}`, {
...init,
headers: {
"x-api-key": COMPOSIO_KEY,
"x-api-key": key,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
Expand Down
87 changes: 87 additions & 0 deletions packages/opencode/src/cli/cmd/stats.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { writeJson } from "./iris-api"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Storage } from "../../storage/storage"
Expand Down Expand Up @@ -71,6 +72,11 @@ export const StatsCommand = cmd({
type: "boolean",
default: false
})
.option("json", {
describe: "emit machine-readable JSON instead of the formatted boxes",
type: "boolean",
default: false,
})
},
handler: async (args) => {
try {
Expand All @@ -84,6 +90,11 @@ export const StatsCommand = cmd({
modelLimit = args.models
}

if (args.json) {
await writeJson(buildStatsJson(stats, args.admin, args.period, toolLimitFor(args.tools), modelLimit))
return
}

if (args.admin) {
displayAdminStats(stats, args.period, args.tools, modelLimit)
} else {
Expand Down Expand Up @@ -324,6 +335,82 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return stats
}

function toolLimitFor(tools?: number): number | undefined {
return tools === undefined ? undefined : tools
}

function buildStatsJson(
stats: SessionStats,
admin: boolean,
period: string,
toolLimit: number | undefined,
modelLimit: number | undefined,
) {
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) =>
admin ? b.cost - a.cost : b.messages - a.messages,
)
const modelsToDisplay =
modelLimit === undefined ? [] : modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools

const models = modelsToDisplay.map(([name, usage]) => ({
name,
messages: usage.messages,
inputTokens: usage.tokens.input,
outputTokens: usage.tokens.output,
cost: usage.cost,
}))

const tools = toolsToDisplay.map(([name, count]) => ({ name, count }))

const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession
const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession
const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning

const base = {
totalSessions: stats.totalSessions,
totalMessages: stats.totalMessages,
days: stats.days,
cost: { total: cost, perDay: costPerDay },
tokens: {
input: stats.totalTokens.input,
output: stats.totalTokens.output,
reasoning: stats.totalTokens.reasoning,
cacheRead: stats.totalTokens.cache.read,
cacheWrite: stats.totalTokens.cache.write,
perSession: tokensPerSession,
medianPerSession: medianTokensPerSession,
},
models,
tools,
dateRange: stats.dateRange,
}

if (!admin) {
return base
}

const cacheHitRate = totalTokens > 0 ? (stats.totalTokens.cache.read / totalTokens) * 100 : 0
return {
...base,
admin: {
period,
avgSessionsPerDay: Math.round(stats.totalSessions / Math.max(1, stats.days)),
avgMessagesPerSession: Math.round(stats.totalMessages / Math.max(1, stats.totalSessions)),
costPerSession: stats.totalSessions > 0 ? cost / stats.totalSessions : 0,
cacheHitRate,
efficiencyScore: calculateEfficiencyScore(stats),
utilizationRate: calculateUtilizationRate(stats),
costEfficiency: calculateCostEfficiency(stats),
healthStatus: getHealthStatus(stats),
insights: generateInsights(stats),
},
}
}

export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56

Expand Down
Loading