Skip to content
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@

### Changed

- `--help` no longer boots the engine to print a help screen. The usage text and the flags extensions register are answered from a cache of the last launch's flag set (`<agentDir>/cache/help-flags.json`, validated against the engine version and the mtime/size of every extension, settings and trust input, so an upgrade or an edited extension refreshes it); a cache miss loads extensions for their flags only and skips the model runtime, the session and every other resource class. Measured warm on an Apple M4 Pro: 790ms → 28ms on bun and 959ms → 59ms on node for `--help`; a help screen never prompts for project trust and never runs project-local extension code that is not already trusted. ([oh-my-openagent#8371](https://github.com/code-yeongyu/oh-my-openagent/issues/8371))

### Fixed

- The startup spinner is drawn the moment interactive startup begins instead of after a 120ms grace timer. That timer could not fire while the synchronous extension imports it was meant to cover were running, so on a real terminal the first frame appeared only once the whole load was done (measured: first byte at 2.27s, one frame before the TUI took over) and the load ran on a blank screen. ([oh-my-openagent#8371](https://github.com/code-yeongyu/oh-my-openagent/issues/8371))

### Removed

## [2026.9.16-2] - 2026-09-16
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# changes

## 2026-09-16 - Answer `--help` without booting the engine (oh-my-openagent#8371)

### What changed

- `packages/coding-agent/src/cli.ts`: a plain root `--help`/`-h` is answered before `cli-main` is imported when `cli/help-fast-path.ts` finds a valid flags cache for this cwd, agent dir, `--extension` set and project-trust decision; the import stays dynamic for the same reason the `cli-main` import is. `--no-extensions` is answered without any cache.
- `packages/coding-agent/src/main.ts`: a plain `--help` stops right after CLI paths are resolved. It resolves extension flags through `cli/help-extension-flags.ts` (a `DefaultResourceLoader` with skills, prompt templates, themes and context files disabled; no `ModelRuntime`, no `SessionManager`, no `AgentSession`), prints help, writes `<agentDir>/cache/help-flags.json` through `cli/help-flags-cache.ts` and exits. Every full launch also refreshes that cache from the runtime's loaded extensions right after the late `parsed.help` branch, which now only serves `--help --mode json` / `-p --help`.
- Project trust for the help path is `--yolo`/override → recorded `trust.json` decision → trusted when the project carries no trust-requiring resources; it never prompts and never loads untrusted project extension code.

### Why

- oh-my-openagent#8371: `omo --help` measured 47.8s on Windows and 790ms warm / 8.8-13.6s cold on bun here, all spent building a runtime the help screen never uses. Cached help now costs 28ms (bun) / 59ms (node); a cache miss costs the extension load only.

### Why an extension could not handle it

- The help screen is printed by the host before any extension is bound, and the cost being removed is the host's own runtime construction.

### Expected merge conflict zones

- MEDIUM: `main.ts` around the `resolveCliPaths` block and the late `if (parsed.help)` branch; LOW: `cli.ts` next to the `--version` fast path.

## 2026-09-16 - Print mode explains provider stalls (senpi#1740)

### What changed
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ if (isRootCommand(args) && (args.includes("--version") || args.includes("-v")))
process.exit();
}

// Help is static text plus the flags extensions registered, so a launch that already knows those
// flags must not import the engine graph to print them. The import stays dynamic for the same
// reason `cli-main` is: a static one would evaluate that graph before this answer.
if (isRootCommand(args) && args.some((arg) => arg === "--help" || arg === "-h")) {
const { tryPrintHelpWithoutEngine } = await import("./cli/help-fast-path.ts");
if (tryPrintHelpWithoutEngine(args)) {
process.exit();
}
}

if (isMissingBundledWorkspaceDependencies(getPackageDir())) {
if (await handleBootstrapSelfUpdate(args)) {
process.exit();
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/cli/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# changes

## 2026-09-16 - Startup spinner draws its first frame synchronously (oh-my-openagent#8371)

### What changed

- `packages/coding-agent/src/cli/startup-loading-indicator.ts`: `start()` writes the first frame itself (hidden cursor + label + phase) and the 120ms grace delay now gates only the animation interval; `resume()` redraws the same way before its grace timer. `setPhase()` therefore renders before any timer fires.

### Why

- The work the indicator covers is synchronous module loading (extension imports through jiti), which starves every timer until it finishes. Measured on a real pty during oh-my-openagent#8371: first spinner byte at 2.27s, a single frame before the TUI replaced it, the whole extension load on a blank terminal. A timer-driven first frame announces work that already ended.

### Why an extension could not handle it

- The indicator runs in the host before any extension is loaded; it is the thing extensions' own load time hides.

### Expected merge conflict zones

- LOW: `start()`, `resume()` and `beginAnimation()` bodies plus the class docstring; `test/startup-loading-indicator.test.ts` grace-delay cases.

## 2026-09-10 - VENICE_API_KEY in the help output

### What changed
Expand Down
44 changes: 44 additions & 0 deletions packages/coding-agent/src/cli/help-extension-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ExtensionFlag, InlineExtension } from "../core/extensions/types.ts";
import { DefaultResourceLoader } from "../core/resource-loader.ts";
import type { SettingsManager } from "../core/settings-manager.ts";

export interface HelpExtensionFlagsResult {
readonly flags: ExtensionFlag[];
readonly extensionPaths: string[];
}

/**
* Load extensions for their CLI flags and nothing else.
*
* Help renders flag descriptors, so this deliberately skips every other resource class and the
* whole model/session stack that `createAgentSessionServices` would build: skills, prompt
* templates, themes and context files cannot register a flag.
*/
export async function resolveHelpExtensionFlags(options: {
readonly cwd: string;
readonly agentDir: string;
readonly settingsManager: SettingsManager;
readonly additionalExtensionPaths: readonly string[];
readonly noExtensions: boolean;
readonly extensionFactories?: readonly InlineExtension[];
}): Promise<HelpExtensionFlagsResult> {
const resourceLoader = new DefaultResourceLoader({
cwd: options.cwd,
agentDir: options.agentDir,
settingsManager: options.settingsManager,
sharedHostEnabled: false,
additionalExtensionPaths: [...options.additionalExtensionPaths],
noExtensions: options.noExtensions,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
...(options.extensionFactories ? { extensionFactories: [...options.extensionFactories] } : {}),
});
await resourceLoader.reload();
const { extensions } = resourceLoader.getExtensions();
return {
flags: extensions.flatMap((extension) => [...extension.flags.values()]),
extensionPaths: extensions.map((extension) => extension.resolvedPath),
};
}
62 changes: 62 additions & 0 deletions packages/coding-agent/src/cli/help-fast-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { getAgentDir } from "../config.ts";
import type { ExtensionFlag } from "../core/extensions/types.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../core/trust-manager.ts";
import { type Args, parseArgs, printHelp } from "./args.ts";
import { type HelpFlagsScope, readHelpFlagsCache } from "./help-flags-cache.ts";

export function isPlainHelpRequest(parsed: Args): boolean {
return parsed.help === true && parsed.print !== true && parsed.mode === undefined;
}

/**
* A help screen never prompts for project trust and never runs project-local extension code
* the user has not already trusted: only a recorded decision, or an explicit override, lets
* project resources in.
*/
export function resolveHelpProjectTrust(parsed: Args, cwd: string, agentDir: string): boolean {
if (parsed.projectTrustOverride !== undefined) return parsed.projectTrustOverride;
if (!hasTrustRequiringProjectResources(cwd)) return true;
return new ProjectTrustStore(agentDir).get(cwd) === true;
}

export function helpFlagsScope(parsed: Args, cwd: string, agentDir: string, projectTrusted: boolean): HelpFlagsScope {
return {
cwd,
agentDir,
cliExtensionPaths: [...(parsed.extensions ?? [])],
noExtensions: parsed.noExtensions === true,
projectTrusted,
};
}

/**
* Answer `--help` before the engine module graph is imported.
*
* Returns false for every launch it cannot answer from what is already known - a non-plain help
* request, or a scope whose cached flags are missing or stale - and the normal startup path then
* resolves the flags and refills the cache.
*/
export function tryPrintHelpWithoutEngine(argv: readonly string[]): boolean {
let parsed: Args;
try {
parsed = parseArgs([...argv]);
} catch {
return false;
}
if (!isPlainHelpRequest(parsed) || parsed.diagnostics.length > 0) return false;
if (parsed.noExtensions === true) {
printHelp([]);
return true;
}
let flags: ExtensionFlag[] | undefined;
try {
const cwd = process.cwd();
const agentDir = getAgentDir();
flags = readHelpFlagsCache(helpFlagsScope(parsed, cwd, agentDir, resolveHelpProjectTrust(parsed, cwd, agentDir)));
} catch {
return false;
}
if (!flags) return false;
printHelp(flags);
return true;
}
145 changes: 145 additions & 0 deletions packages/coding-agent/src/cli/help-flags-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { CONFIG_DIR_NAME, DISPLAY_VERSION } from "../config.ts";
import type { ExtensionFlag } from "../core/extensions/types.ts";

const CACHE_FILE_VERSION = 1;
const MAX_CACHED_SCOPES = 8;

export interface HelpFlagsScope {
readonly cwd: string;
readonly agentDir: string;
readonly cliExtensionPaths: readonly string[];
readonly noExtensions: boolean;
readonly projectTrusted: boolean;
}

interface InputStamp {
readonly path: string;
readonly state: string;
}

interface CachedScope {
readonly writtenAt: number;
readonly appVersion: string;
readonly inputs: readonly InputStamp[];
readonly flags: readonly ExtensionFlag[];
}

interface CacheFile {
readonly version: number;
readonly scopes: Record<string, CachedScope>;
}

function cachePath(agentDir: string): string {
return join(agentDir, "cache", "help-flags.json");
}

function scopeKey(scope: HelpFlagsScope): string {
const material = [
scope.cwd,
scope.noExtensions ? "no-extensions" : "extensions",
scope.projectTrusted ? "trusted" : "untrusted",
...scope.cliExtensionPaths,
].join("\u0000");
return createHash("sha256").update(material).digest("hex").slice(0, 32);
}

/**
* A path's identity for cache validation. Directories carry only their mtime, which is what
* changes when an extension file is added or removed inside them; files carry mtime and size,
* so an in-place upgrade of a bundled plugin invalidates the entry it produced.
*/
function stamp(path: string): string {
try {
const stats = statSync(path);
if (stats.isDirectory()) return `d:${stats.mtimeMs}`;
return `f:${stats.mtimeMs}:${stats.size}`;
} catch {
return "absent";
}
}

function discoveryInputs(scope: HelpFlagsScope, extensionPaths: readonly string[]): string[] {
const paths = new Set<string>([
join(scope.agentDir, "settings.json"),
join(scope.agentDir, "extensions"),
join(scope.agentDir, "trust.json"),
join(scope.cwd, CONFIG_DIR_NAME, "settings.json"),
join(scope.cwd, CONFIG_DIR_NAME, "extensions"),
]);
for (const path of extensionPaths) {
if (!isAbsolute(path)) continue;
paths.add(path);
paths.add(dirname(path));
}
for (const path of scope.cliExtensionPaths) {
if (!isAbsolute(path)) continue;
paths.add(path);
}
return [...paths].sort();
}

function readCacheFile(agentDir: string): CacheFile | undefined {
try {
const parsed = JSON.parse(readFileSync(cachePath(agentDir), "utf8")) as CacheFile;
if (parsed.version !== CACHE_FILE_VERSION || typeof parsed.scopes !== "object" || parsed.scopes === null) {
return undefined;
}
return parsed;
} catch {
return undefined;
}
}

/**
* Flags a previous run resolved for this exact scope, or `undefined` when anything that feeds
* extension discovery changed. Never throws: a help screen must not depend on its own cache.
*/
export function readHelpFlagsCache(scope: HelpFlagsScope): ExtensionFlag[] | undefined {
const cached = readCacheFile(scope.agentDir)?.scopes[scopeKey(scope)];
if (!cached || cached.appVersion !== DISPLAY_VERSION || !Array.isArray(cached.inputs)) return undefined;
for (const input of cached.inputs) {
if (stamp(input.path) !== input.state) return undefined;
}
return [...cached.flags];
}

/**
* Record the flags a full extension load produced. Failure is silent by contract: this runs on
* the startup path, where a cache write must never be the reason a launch fails.
*/
export function writeHelpFlagsCache(options: {
readonly scope: HelpFlagsScope;
readonly flags: readonly ExtensionFlag[];
readonly extensionPaths: readonly string[];
}): void {
const { scope, flags, extensionPaths } = options;
try {
const existing = readCacheFile(scope.agentDir);
const scopes: Record<string, CachedScope> = { ...(existing?.scopes ?? {}) };
scopes[scopeKey(scope)] = {
writtenAt: Date.now(),
appVersion: DISPLAY_VERSION,
inputs: discoveryInputs(scope, extensionPaths).map((path) => ({ path, state: stamp(path) })),
flags: [...flags],
};
const keptEntries = Object.entries(scopes)
.sort(([, left], [, right]) => right.writtenAt - left.writtenAt)
.slice(0, MAX_CACHED_SCOPES);
const file: CacheFile = { version: CACHE_FILE_VERSION, scopes: Object.fromEntries(keptEntries) };
const target = cachePath(scope.agentDir);
mkdirSync(dirname(target), { recursive: true });
const temporary = `${target}.${process.pid}.tmp`;
writeFileSync(temporary, JSON.stringify(file), { mode: 0o600 });
try {
renameSync(temporary, target);
} catch (error) {
if (existsSync(temporary)) rmSync(temporary, { force: true });
throw error;
}
} catch {
return;
}
}
13 changes: 9 additions & 4 deletions packages/coding-agent/src/cli/startup-loading-indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ export interface StartupLoadingIndicator {
/**
* Single-line ANSI loading indicator for the pre-TUI startup window, borrowed
* from codex's UI-first startup design (codex-rs/tui keeps a dim placeholder
* header until the session is configured). The grace delay keeps fast startups
* flash-free; stop() must run before any other stdout writer (TUI, prompts,
* help) takes over the terminal.
* header until the session is configured). The first frame is written in
* start() itself: the work it covers is synchronous module loading, which
* starves every timer until it is done, so a timer-driven first frame lands
* only after that work (measured: 2.27s to the first byte on a real pty). The
* grace delay now gates the animation only; stop() must run before any other
* stdout writer (TUI, prompts, help) takes over the terminal.
*/
class AnsiStartupLoadingIndicator implements StartupLoadingIndicator {
private readonly writer: (chunk: string) => void;
Expand Down Expand Up @@ -75,6 +78,7 @@ class AnsiStartupLoadingIndicator implements StartupLoadingIndicator {
if (this.drawn) this.writer(CLEAR_LINE + SHOW_CURSOR);
};
process.on("exit", this.exitListener);
this.draw(true);
this.startGraceTimer();
}

Expand All @@ -98,6 +102,7 @@ class AnsiStartupLoadingIndicator implements StartupLoadingIndicator {
if (this.graceElapsed) {
this.beginAnimation();
} else {
this.draw(true);
this.startGraceTimer();
}
}
Expand All @@ -121,7 +126,7 @@ class AnsiStartupLoadingIndicator implements StartupLoadingIndicator {
}

private beginAnimation(): void {
this.draw(true);
if (!this.drawn) this.draw(true);
this.frameTimer = setInterval(() => {
this.frameIndex = (this.frameIndex + 1) % this.frames.length;
this.draw(false);
Expand Down
Loading