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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ode",
"version": "0.0.87",
"version": "0.0.88",
"description": "Coding anywhere with your coding agents connected",
"module": "packages/core/index.ts",
"type": "module",
Expand Down
16 changes: 10 additions & 6 deletions packages/config/dashboard-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
parseStatusMessageFrequencyMs,
type StatusMessageFrequencyMs,
} from "./status-message-frequency";

export type DashboardConfig = {
completeOnboarding: boolean;
user: {
Expand All @@ -8,7 +14,7 @@ export type DashboardConfig = {
gitStrategy: "default" | "worktree";
defaultStatusMessageFormat: "aggressive" | "medium" | "minimum";
defaultMessageFrequency?: "aggressive" | "medium" | "minimum";
statusMessageFrequencyMs?: 2000 | 5000 | 10000;
statusMessageFrequencyMs?: StatusMessageFrequencyMs;
};
updates: {
autoUpgrade: boolean;
Expand Down Expand Up @@ -91,7 +97,7 @@ export const defaultDashboardConfig: DashboardConfig = {
email: "",
gitStrategy: "worktree",
defaultStatusMessageFormat: "medium",
statusMessageFrequencyMs: 2000,
statusMessageFrequencyMs: DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
},
updates: {
autoUpgrade: true,
Expand Down Expand Up @@ -135,10 +141,8 @@ const asFrequency = (
return "medium";
};

const asStatusMessageFrequencyMs = (value: unknown): 2000 | 5000 | 10000 => {
if (value === 5000 || value === 10000) return value;
return 2000;
};
const asStatusMessageFrequencyMs = (value: unknown): StatusMessageFrequencyMs =>
parseStatusMessageFrequencyMs(value);

const asGitStrategy = (
value: unknown
Expand Down
12 changes: 12 additions & 0 deletions packages/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ export {
type StatusMessageFormat,
} from "./status-message-format";

export {
STATUS_MESSAGE_FREQUENCY_OPTIONS,
DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
isStatusMessageFrequencyMs,
parseStatusMessageFrequencyMs,
isStatusMessageFrequencyValue,
parseStatusMessageFrequencyValue,
toStatusMessageFrequencyValue,
type StatusMessageFrequencyMs,
type StatusMessageFrequencyValue,
} from "./status-message-frequency";

export { resolveMessageUpdateIntervalMs } from "./message-update-interval";

export { resolveGitStrategy, type GitStrategy } from "./git-strategy";
Expand Down
33 changes: 13 additions & 20 deletions packages/config/local/ode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
sanitizeDashboardConfig,
type DashboardConfig,
} from "../dashboard-config";
import {
DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
parseStatusMessageFrequencyMs,
type StatusMessageFrequencyMs,
} from "../status-message-frequency";

const existsSync = fs.existsSync;
const mkdirSync = fs.mkdirSync;
Expand Down Expand Up @@ -40,7 +45,7 @@ const userSchema = z.object({
"high",
]).optional(),
messageUpdateIntervalMs: z.number().optional(),
IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(2000),
IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(DEFAULT_STATUS_MESSAGE_FREQUENCY_MS),
});

const agentProviderSchema = z.enum(["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]);
Expand Down Expand Up @@ -103,7 +108,7 @@ const channelDetailSchema = z.object({

const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
const MIN_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = 2000;
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = DEFAULT_STATUS_MESSAGE_FREQUENCY_MS;
const MIN_MESSAGE_UPDATE_INTERVAL_MS = 250;
export const DEFAULT_CODEX_MODEL = "gpt-5.3-codex";

Expand Down Expand Up @@ -170,7 +175,7 @@ const EMPTY_TEMPLATE: OdeConfig = {
avatar: "",
gitStrategy: "worktree",
defaultStatusMessageFormat: "medium",
IM_MESSAGE_UPDATE_INTERVAL_MS: 2000,
IM_MESSAGE_UPDATE_INTERVAL_MS: DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
},
githubInfos: {},
agents: {
Expand Down Expand Up @@ -366,10 +371,7 @@ function toDashboardConfig(config: OdeConfig): DashboardConfig {
avatar: config.user.avatar,
gitStrategy: config.user.gitStrategy,
defaultStatusMessageFormat,
statusMessageFrequencyMs:
config.user.IM_MESSAGE_UPDATE_INTERVAL_MS === 5000 || config.user.IM_MESSAGE_UPDATE_INTERVAL_MS === 10000
? config.user.IM_MESSAGE_UPDATE_INTERVAL_MS
: 2000,
statusMessageFrequencyMs: parseStatusMessageFrequencyMs(config.user.IM_MESSAGE_UPDATE_INTERVAL_MS),
},
updates: {
autoUpgrade: config.updates.autoUpgrade,
Expand Down Expand Up @@ -405,10 +407,7 @@ function mergeDashboardConfig(config: OdeConfig, dashboardConfig: DashboardConfi
user: {
...config.user,
...dashboardUser,
IM_MESSAGE_UPDATE_INTERVAL_MS:
statusMessageFrequencyMs === 5000 || statusMessageFrequencyMs === 10000
? statusMessageFrequencyMs
: 2000,
IM_MESSAGE_UPDATE_INTERVAL_MS: parseStatusMessageFrequencyMs(statusMessageFrequencyMs),
},
updates: {
...config.updates,
Expand Down Expand Up @@ -630,7 +629,7 @@ export type GitHubInfo = {
export type UserGeneralSettings = {
defaultStatusMessageFormat: "minimum" | "medium" | "aggressive";
gitStrategy: "default" | "worktree";
statusMessageFrequencyMs: 2000 | 5000 | 10000;
statusMessageFrequencyMs: StatusMessageFrequencyMs;
autoUpdate: boolean;
};

Expand Down Expand Up @@ -658,10 +657,7 @@ export function getUserGeneralSettings(): UserGeneralSettings {
? user.defaultStatusMessageFormat
: "medium",
gitStrategy: user.gitStrategy === "default" ? "default" : "worktree",
statusMessageFrequencyMs:
user.IM_MESSAGE_UPDATE_INTERVAL_MS === 5000 || user.IM_MESSAGE_UPDATE_INTERVAL_MS === 10000
? user.IM_MESSAGE_UPDATE_INTERVAL_MS
: 2000,
statusMessageFrequencyMs: parseStatusMessageFrequencyMs(user.IM_MESSAGE_UPDATE_INTERVAL_MS),
autoUpdate: updates.autoUpgrade !== false,
};
}
Expand All @@ -673,10 +669,7 @@ export function setUserGeneralSettings(settings: UserGeneralSettings): void {
...config.user,
defaultStatusMessageFormat: settings.defaultStatusMessageFormat,
gitStrategy: settings.gitStrategy,
IM_MESSAGE_UPDATE_INTERVAL_MS:
settings.statusMessageFrequencyMs === 5000 || settings.statusMessageFrequencyMs === 10000
? settings.statusMessageFrequencyMs
: 2000,
IM_MESSAGE_UPDATE_INTERVAL_MS: parseStatusMessageFrequencyMs(settings.statusMessageFrequencyMs),
},
updates: {
...config.updates,
Expand Down
3 changes: 2 additions & 1 deletion packages/config/message-update-interval.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { loadOdeConfig } from "./local/ode";
import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "./status-message-frequency";

const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = 2000;
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = DEFAULT_STATUS_MESSAGE_FREQUENCY_MS;
const MIN_MESSAGE_UPDATE_INTERVAL_MS = 250;

export function resolveMessageUpdateIntervalMs(): number {
Expand Down
31 changes: 31 additions & 0 deletions packages/config/status-message-frequency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const STATUS_MESSAGE_FREQUENCY_OPTIONS = [
{ ms: 5000, value: "5000", label: "5 seconds" },
{ ms: 8000, value: "8000", label: "8 seconds" },
{ ms: 12000, value: "12000", label: "12 seconds" },
] as const;

export type StatusMessageFrequencyMs = (typeof STATUS_MESSAGE_FREQUENCY_OPTIONS)[number]["ms"];
export type StatusMessageFrequencyValue = (typeof STATUS_MESSAGE_FREQUENCY_OPTIONS)[number]["value"];

export const DEFAULT_STATUS_MESSAGE_FREQUENCY_MS: StatusMessageFrequencyMs = 5000;

export function isStatusMessageFrequencyMs(value: unknown): value is StatusMessageFrequencyMs {
return value === 5000 || value === 8000 || value === 12000;
}

export function parseStatusMessageFrequencyMs(value: unknown): StatusMessageFrequencyMs {
return isStatusMessageFrequencyMs(value) ? value : DEFAULT_STATUS_MESSAGE_FREQUENCY_MS;
}

export function isStatusMessageFrequencyValue(value: string): value is StatusMessageFrequencyValue {
return value === "5000" || value === "8000" || value === "12000";
}

export function parseStatusMessageFrequencyValue(value: string): StatusMessageFrequencyValue | null {
const normalized = value.trim();
return isStatusMessageFrequencyValue(normalized) ? normalized : null;
}

export function toStatusMessageFrequencyValue(ms: StatusMessageFrequencyMs): StatusMessageFrequencyValue {
return String(ms) as StatusMessageFrequencyValue;
}
44 changes: 8 additions & 36 deletions packages/core/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env bun

import { spawn } from "child_process";
import { closeSync, openSync, readSync, statSync } from "fs";
import packageJson from "../../package.json" with { type: "json" };
import { getWebHost, getWebPort } from "@/config";
import { runDaemon } from "@/core/daemon/manager";
Expand All @@ -21,8 +20,6 @@ const READY_POLL_MS = 500;
const STOP_WAIT_MS = 30 * 1000;
const STOP_POLL_MS = 500;
const DAEMON_SPAWN_THROTTLE_MS = 3000;
const LOG_TAIL_BYTES = 200_000;
const LOG_TAIL_LINES = 40;
let lastDaemonSpawnAttemptAt = 0;

const foregroundRequested = rawArgs.includes("--foreground");
Expand Down Expand Up @@ -159,52 +156,27 @@ async function startBackground(): Promise<void> {
console.log(`Ode daemon is still starting. Follow logs at ${getDaemonLogPath()}`);
}

function tailLogs(maxLines: number): string[] {
const logPath = getDaemonLogPath();
try {
const stats = statSync(logPath);
if (stats.size === 0) return [];
const bytes = Math.min(LOG_TAIL_BYTES, stats.size);
const buffer = Buffer.alloc(Number(bytes));
const fd = openSync(logPath, "r");
try {
readSync(fd, buffer, 0, Number(bytes), stats.size - bytes);
} finally {
closeSync(fd);
}
const content = buffer.toString("utf-8");
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
return lines.slice(-maxLines);
} catch {
return [];
}
}

function formatTimestamp(value: number | null): string {
if (!value) return "n/a";
return new Date(value).toLocaleString();
}

async function showStatus(): Promise<void> {
const state = daemonState();
const daemonStatus = managerRunning(state) ? `running (pid ${state.managerPid})` : "stopped";
const runtimeStatus = runtimeRunning(state) ? `running (pid ${state.runtimePid})` : "stopped";
console.log(`Daemon: ${daemonStatus}`);
console.log(`Runtime: ${runtimeStatus}`);
console.log(`Last start: ${formatTimestamp(state.lastStartAt)}`);
console.log(`Last ready: ${formatTimestamp(state.lastReadyAt)}`);
const daemonIsRunning = managerRunning(state);
console.log(`Daemon: ${daemonIsRunning ? "running" : "stopped"}`);
if (state.pendingUpgradeRestart) {
console.log(
`Pending upgrade restart since ${formatTimestamp(state.pendingUpgradeRestart.scheduledAt)} (${state.pendingUpgradeRestart.reason})`,
`Upgrade: pending restart since ${formatTimestamp(state.pendingUpgradeRestart.scheduledAt)} (${state.pendingUpgradeRestart.reason})`,
);
} else {
console.log("Upgrade: none pending");
}
const logs = tailLogs(LOG_TAIL_LINES);
if (logs.length === 0) {
console.log(`No logs yet. Log file: ${getDaemonLogPath()}`);
if (daemonIsRunning) {
console.log("ode is running, setting UI is running on localhost:9293...");
return;
}
console.log(`Recent logs (${getDaemonLogPath()}):`);
console.log(logs.join("\n"));
console.log("ode is installed but not running, can run it with ode");
}

async function restartDaemonCommand(): Promise<void> {
Expand Down
19 changes: 18 additions & 1 deletion packages/core/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,17 @@ export function createCoreRuntime(deps: RuntimeDeps) {
const statusFormat = resolveStatusMessageFormat();
const finalChunks = splitResultMessage(text);
const singleChunk = finalChunks[0] ?? text;
const statusRateLimited = runtimeDeps.im.wasRateLimited?.(channelId, statusTs) ?? false;

if (finalChunks.length > 1) {
if (statusFormat !== "aggressive") {
if (statusFormat !== "aggressive" && !statusRateLimited) {
await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below in multiple messages.", false);
} else if (statusRateLimited) {
log.warn("Skipping final status update due to prior 429; posting final chunks as new messages", {
channelId,
threadId,
statusTs,
});
}

for (const chunk of finalChunks) {
Expand All @@ -164,6 +171,16 @@ export function createCoreRuntime(deps: RuntimeDeps) {
return;
}

if (statusRateLimited) {
log.warn("Skipping final status edit due to prior 429; posting final result as new message", {
channelId,
threadId,
statusTs,
});
await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk, true);
return;
}

const maxEditableMessageChars = runtimeDeps.im.maxEditableMessageChars;
if (typeof maxEditableMessageChars === "number" && singleChunk.length > maxEditableMessageChars) {
await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below.", false);
Expand Down
24 changes: 24 additions & 0 deletions packages/core/runtime/message-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ type QueuedUpdate = {
resolve: () => void;
};

function isRateLimitError(error: unknown): boolean {
const message = String(error || "").toLowerCase();
return message.includes("429") || message.includes("rate limit") || message.includes("ratelimit") || message.includes("rate_limited");
}

export function createRateLimitedImAdapter(
im: IMAdapter,
intervalMs = resolveMessageUpdateIntervalMs()
): IMAdapter {
let globalLastUpdateAt = 0;
const queue: QueuedUpdate[] = [];
let processing = false;
const rateLimitedMessages = new Set<string>();

function key(channelId: string, messageTs: string): string {
return `${channelId}:${messageTs}`;
}

async function processQueue(): Promise<void> {
if (processing || queue.length === 0) return;
Expand All @@ -35,6 +45,14 @@ export function createRateLimitedImAdapter(
try {
await im.updateMessage(item.channelId, item.messageTs, item.text, item.asMarkdown);
} catch (error) {
if (isRateLimitError(error)) {
rateLimitedMessages.add(key(item.channelId, item.messageTs));
log.warn("IM message update hit rate limit (429)", {
channelId: item.channelId,
messageTs: item.messageTs,
error: String(error),
});
}
log.debug("IM message update failed", {
channelId: item.channelId,
messageTs: item.messageTs,
Expand All @@ -50,6 +68,12 @@ export function createRateLimitedImAdapter(

return {
...im,
wasRateLimited: (channelId: string, messageTs: string): boolean => {
if (typeof im.wasRateLimited === "function" && im.wasRateLimited(channelId, messageTs)) {
return true;
}
return rateLimitedMessages.has(key(channelId, messageTs));
},
updateMessage: async (
channelId: string,
messageTs: string,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/runtime/open-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export async function runOpenRequest(params: {
stateKey: statusMessageKey,
liveParsedState,
startedAt: request.startedAt,
onTitleGenerated: async (title) => {
if (!deps.im.renameThread) return;
await deps.im.renameThread(context.channelId, context.replyThreadId, title);
},
});

const progressIntervalMs = resolveMessageUpdateIntervalMs();
Expand Down
4 changes: 4 additions & 0 deletions packages/core/runtime/selection-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams):
stateKey: statusMessageKey,
liveParsedState: state.liveParsedState,
startedAt: request.startedAt,
onTitleGenerated: async (title) => {
if (!deps.im.renameThread) return;
await deps.im.renameThread(channelId, threadId, title);
},
});

const session = loadSession(channelId, threadId);
Expand Down
Loading