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
84 changes: 84 additions & 0 deletions packages/agents/test/session-inspector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,90 @@ describe("session inspector", () => {
expect(text).not.toContain("cost 0");
});

it("hydrates OpenCode token usage from nested message info shape", () => {
const startedAt = Date.now();
const state = buildSessionMessageState([
{
timestamp: startedAt,
type: "message.updated",
data: {
payload: {
type: "message.updated",
properties: {
message: {
info: {
title: "Refactor live status parser",
modelId: "gpt-5.3-codex",
agentName: "build",
usage: {
total_tokens: 1024,
input_tokens: 200,
output_tokens: 120,
reasoning_tokens: 4,
cache_tokens: {
input_tokens: 700,
output_tokens: 0,
},
},
},
},
},
},
},
},
]);

expect(state.sessionTitle).toBeUndefined();
expect(state.model).toBe("gpt-5.3-codex");
expect(state.agent).toBe("build");
expect(state.tokenUsage?.total).toBe(1024);
expect(state.tokenUsage?.cacheRead).toBe(700);
});

it("does not clear token usage when later message.updated has empty usage object", () => {
const startedAt = Date.now();
const state = buildSessionMessageState([
{
timestamp: startedAt,
type: "message.updated",
data: {
payload: {
type: "message.updated",
properties: {
info: {
modelID: "gpt-5.3-codex",
tokens: {
total: 2048,
input: 500,
output: 200,
reasoning: 10,
cache: { read: 1338, write: 0 },
},
},
},
},
},
},
{
timestamp: startedAt + 1,
type: "message.updated",
data: {
payload: {
type: "message.updated",
properties: {
info: {
tokens: {},
},
},
},
},
},
]);

expect(state.tokenUsage?.total).toBe(2048);
expect(state.tokenUsage?.input).toBe(500);
});

it("parses todo.updated aliases from wrapped payload events", () => {
const now = Date.now();
const state = buildSessionMessageState([
Expand Down
11 changes: 3 additions & 8 deletions packages/config/dashboard-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
parseStatusMessageFrequencyMs,
type StatusMessageFrequencyMs,
} from "./status-message-frequency";
import { isAgentProviderId, type AgentProviderId } from "@/shared/agent-provider";

export type DashboardConfig = {
completeOnboarding: boolean;
Expand Down Expand Up @@ -69,7 +70,7 @@ export type DashboardConfig = {
channelDetails: {
id: string;
name: string;
agentProvider?: "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini";
agentProvider?: AgentProviderId;
model: string;
workingDirectory: string;
baseBranch: string;
Expand Down Expand Up @@ -152,16 +153,10 @@ const asGitStrategy = (
const asStatus = (value: unknown): DashboardConfig["workspaces"][number]["status"] =>
value === "paused" ? "paused" : "active";

const KNOWN_AGENT_PROVIDERS = new Set<NonNullable<
DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]
>>(["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]);

function isKnownAgentProvider(
value: string
): value is NonNullable<DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]> {
return KNOWN_AGENT_PROVIDERS.has(value as NonNullable<
DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]
>);
return isAgentProviderId(value);
}

const asAgentProvider = (
Expand Down
131 changes: 131 additions & 0 deletions packages/config/local/ode-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { normalizeCwd } from "../paths";
import { isAgentProviderId } from "@/shared/agent-provider";
import {
type AgentProvider,
type ChannelDetail,
type WorkspaceConfig,
} from "./ode-schema";
import {
loadOdeConfig,
normalizeBaseBranch,
updateOdeConfig,
} from "./ode-store";

export type ChannelCwdInfo = {
cwd: string;
workingDirectory: string | null;
hasCustomCwd: boolean;
};

export function getDefaultCwd(): string {
return normalizeCwd(process.cwd());
}

function getWorkspaces(): WorkspaceConfig[] {
return loadOdeConfig().workspaces;
}

export function getChannelDetails(channelId: string): ChannelDetail | null {
for (const workspace of getWorkspaces()) {
const match = workspace.channelDetails.find((channel) => channel.id === channelId);
if (match) return match;
}
return null;
}

export function resolveChannelCwd(channelId: string): ChannelCwdInfo {
const channel = getChannelDetails(channelId);
const workingDirectory = channel?.workingDirectory?.trim();
const normalized = workingDirectory && workingDirectory.length > 0
? normalizeCwd(workingDirectory)
: null;
return {
cwd: normalized ?? getDefaultCwd(),
workingDirectory: normalized,
hasCustomCwd: Boolean(normalized),
};
}

export function setChannelCwd(channelId: string, cwd: string): void {
updateChannel(channelId, (channel) => ({
...channel,
workingDirectory: normalizeCwd(cwd),
}));
}

export function setChannelWorkingDirectory(channelId: string, workingDirectory: string | null): void {
const normalized = workingDirectory && workingDirectory.trim().length > 0
? normalizeCwd(workingDirectory)
: "";
updateChannel(channelId, (channel) => ({
...channel,
workingDirectory: normalized,
}));
}

export function getChannelBaseBranch(channelId: string): string {
return normalizeBaseBranch(getChannelDetails(channelId)?.baseBranch);
}

export function setChannelBaseBranch(channelId: string, baseBranch: string | null): void {
const normalized = normalizeBaseBranch(baseBranch);
updateChannel(channelId, (channel) => ({
...channel,
baseBranch: normalized,
}));
}

export function getChannelSystemMessage(channelId: string): string | null {
return getChannelDetails(channelId)?.channelSystemMessage ?? null;
}

export function setChannelSystemMessage(channelId: string, channelSystemMessage: string | null): void {
const normalized = channelSystemMessage?.trim() ?? "";
updateChannel(channelId, (channel) => ({
...channel,
channelSystemMessage: normalized,
}));
}

export function getChannelModel(channelId: string): string | null {
return getChannelDetails(channelId)?.model ?? null;
}

export function getChannelAgentProvider(channelId: string): AgentProvider {
const provider = getChannelDetails(channelId)?.agentProvider;
return isAgentProviderId(provider) ? provider : "opencode";
}

export function setChannelModel(channelId: string, model: string): void {
updateChannel(channelId, (channel) => ({ ...channel, model }));
}

export function setChannelAgentProvider(
channelId: string,
agentProvider: AgentProvider
): void {
updateChannel(channelId, (channel) => ({ ...channel, agentProvider }));
}

function updateChannel(
channelId: string,
updater: (channel: ChannelDetail) => ChannelDetail
): void {
let updated = false;
updateOdeConfig((config) => {
const workspaces = config.workspaces.map((workspace) => {
const channelDetails = workspace.channelDetails.map((channel) => {
if (channel.id !== channelId) return channel;
updated = true;
return updater(channel);
});
return { ...workspace, channelDetails };
});

if (!updated) {
throw new Error("Channel not found in ~/.config/ode/ode.json");
}

return { ...config, workspaces };
});
}
128 changes: 128 additions & 0 deletions packages/config/local/ode-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { z } from "zod";
import { AGENT_PROVIDERS } from "@/shared/agent-provider";
import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "../status-message-frequency";

const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000;

const userSchema = z.object({
name: z.string().optional().default(""),
email: z.string().optional().default(""),
initials: z.string().optional().default(""),
avatar: z.string().optional().default(""),
gitStrategy: z.enum(["default", "worktree"]).optional().default("worktree"),
defaultStatusMessageFormat: z.enum(["minimum", "medium", "aggressive"]).optional().default("medium"),
defaultMessageFrequency: z.enum(["minimum", "medium", "aggressive"]).optional(),
messageUpdateIntervalMs: z.number().optional(),
IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(DEFAULT_STATUS_MESSAGE_FREQUENCY_MS),
});

export const agentProviderSchema = z.enum(AGENT_PROVIDERS);

const agentsSchema = z.object({
opencode: z.object({
enabled: z.boolean().optional().default(true),
models: z.array(z.string()).optional().default([]),
}).optional().default({ enabled: true, models: [] }),
claudecode: z.object({
enabled: z.boolean().optional().default(true),
}).optional().default({ enabled: true }),
codex: z.object({
enabled: z.boolean().optional().default(true),
models: z.array(z.string()).optional().default([]),
}).optional().default({ enabled: true, models: [] }),
kimi: z.object({
enabled: z.boolean().optional().default(true),
}).optional().default({ enabled: true }),
kiro: z.object({
enabled: z.boolean().optional().default(true),
}).optional().default({ enabled: true }),
kilo: z.object({
enabled: z.boolean().optional().default(true),
models: z.array(z.string()).optional().default([]),
}).optional().default({ enabled: true, models: [] }),
qwen: z.object({
enabled: z.boolean().optional().default(true),
}).optional().default({ enabled: true }),
goose: z.object({
enabled: z.boolean().optional().default(true),
}).optional().default({ enabled: true }),
gemini: z.object({
enabled: z.boolean().optional().default(true),
}).optional().default({ enabled: true }),
}).optional().default({
opencode: { enabled: true, models: [] },
claudecode: { enabled: true },
codex: { enabled: true, models: [] },
kimi: { enabled: true },
kiro: { enabled: true },
kilo: { enabled: true, models: [] },
qwen: { enabled: true },
goose: { enabled: true },
gemini: { enabled: true },
});

const channelDetailSchema = z.object({
id: z.string(),
name: z.string(),
agentProvider: z.preprocess(
(value) => (value === "claude" ? "claudecode" : value),
agentProviderSchema.optional().default("opencode")
),
model: z.string().optional().default(""),
workingDirectory: z.string().optional().default(""),
baseBranch: z.string().optional().default("main"),
channelSystemMessage: z.string().optional().default(""),
});

const updateSchema = z.object({
autoUpgrade: z.boolean().optional().default(true),
checkIntervalMs: z.number().optional().default(DEFAULT_UPDATE_INTERVAL_MS),
});

const workspaceSchema = z.object({
id: z.string(),
type: z.enum(["slack", "discord", "lark"]).optional().default("slack"),
name: z.string().optional().default(""),
domain: z.string().optional().default(""),
status: z.enum(["active", "paused"]).optional().default("active"),
channels: z.number().optional().default(0),
members: z.number().optional().default(0),
lastSync: z.string().optional().default(""),
slackAppToken: z.string().optional().default(""),
slackBotToken: z.string().optional().default(""),
discordBotToken: z.string().optional().default(""),
larkAppKey: z.string().optional().default(""),
larkAppId: z.string().optional().default(""),
larkAppSecret: z.string().optional().default(""),
channelDetails: z.array(channelDetailSchema).optional().default([]),
});

export const odeConfigSchema = z.object({
user: userSchema,
githubInfos: z
.record(
z.string(),
z.object({
token: z.string().optional().default(""),
gitName: z.string().optional().default(""),
gitEmail: z.string().optional().default(""),
})
)
.optional()
.default({}),
agents: agentsSchema,
completeOnboarding: z.boolean().optional().default(false),
workspaces: z.array(workspaceSchema),
updates: updateSchema.optional().default({
autoUpgrade: true,
checkIntervalMs: DEFAULT_UPDATE_INTERVAL_MS,
}),
});

export type ChannelDetail = z.infer<typeof channelDetailSchema>;
export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
export type AgentProvider = z.infer<typeof agentProviderSchema>;
export type AgentsConfig = z.infer<typeof agentsSchema>;
export type UpdateConfig = z.infer<typeof updateSchema>;
export type OdeConfig = z.infer<typeof odeConfigSchema>;
export type UserConfig = z.infer<typeof userSchema>;
Loading