Codex cannot get results from delayed handlers registerd with registerCliHandler

Codex cannot read registerCliHandler results when the handler returns after a short delay

I found what looks like a bug or incompatibility between Codex and Obsidian CLI plugin commands.

I ran into this while building a plugin that extends the Obsidian CLI for automation / agent use.

Versions and environments

  • NixOS/install via home-manager

  • Obsidian Installer 1.12.7

  • Obsidian: 1.12.7

  • Codex: 118.0

Summary

If a community plugin registers a CLI command with plugin.registerCliHandler(...), Codex can read the command output when the handler returns quickly, but Codex cannot read the result when the handler returns after a short delay.

I put a minimal reproduction plugin here:

https://github.com/daichi-629/long-run-command-bug-repro-plugin

Minimal plugin reproduction

import { Plugin, type CliHandler } from "obsidian";

export default class ReproPlugin extends Plugin {
  async onload(): Promise<void> {
    const handler: CliHandler = async () => {
      const start = Date.now();
      await new Promise((resolve) => window.setTimeout(resolve, 20));
      return JSON.stringify(
        { durationMs: Date.now() - start },
        null,
        2
      );
    };

    this.registerCliHandler("repro-sleep", "repro", null, handler);
  }
}

Expected

obsidian repro-sleep

should return JSON like:

{
  "durationMs": 20
}

Comparison

Works:

  • Running the same command directly from a shell returns the expected JSON.
  • Telling Codex to invoke the command in TTY mode also returns the expected JSON.
  • In Codex normal mode, a delay around 15ms still seems to work.

Fails:

  • In Codex normal mode, a delay around 20ms starts producing empty stdout.
  • Exit code is still 0.

So this looks like a timing-dependent failure around the Codex execution path for Obsidian CLI plugin handlers.

What I checked

  • I had Codex repeatedly run the command while varying the delay.
  • This does not seem to be a general delayed stdout problem.
  • The issue seems to show up specifically when a registerCliHandler(...) command returns only after a slightly longer delay.

My main question is:

  • is this likely a Codex-side stdout capture issue?
  • or is this likely an Obsidian CLI / registerCliHandler(...) issue?

As you don’t show your Codex prompt, hard to say. Maybe you have to tell it to launch the command and wait until it outputs?

I just hit the exact same issue while building a CLI surface for an Obsidian plugin (20 commands across status/archive/job/sync/profile-crawl/import/share/tags/etc.). Some additional data points that may help narrow it down — and suggest it’s not a Codex-specific issue.

Confirmed: not consumer-specific

I reproduced this with a plain bash script that just pipes to grep (no Codex, no MCP, no exotic shell). Same symptom: handler returns Promise<string>, Obsidian app actually evaluates the body correctly, but stdout is empty when the Promise resolution crosses the macrotask boundary.

$ obsidian "social-archiver:_debug" delay=0 format=json 2>&1 | grep -v "installer\|Loading"
{ "ok": true, "command": "social-archiver:_debug", "data": { "delayMs": 0, ... } }

$ obsidian "social-archiver:_debug" delay=100 format=json 2>&1 | grep -v "installer\|Loading"
(empty)

Cutoff is even tighter than 15–20 ms

I bisected the delay:

setTimeout delay Output captured
0 ms (microtask only) :white_check_mark:
await Promise.resolve() Ă— 100 (microtask chain) :white_check_mark:
1 ms :white_check_mark: (often coalesces with current event-loop tick)
5 ms :white_check_mark:
10 ms :cross_mark: empty
20 ms :cross_mark: empty
100 ms+ :cross_mark: empty

The trigger appears to be the moment the Promise resolution requires the event loop to handle a macrotask (setTimeout, real I/O), not a wall-clock threshold per se.

Affects any real I/O — not just setTimeout

I tested both Obsidian’s requestUrl() and the standard Web fetch() against a short health-check endpoint:

const handler = async () => {
  const r = await fetch('https://example.com/api/health');
  return JSON.stringify({ status: r.status });
};

Both produce empty stdout, even though the network call completes and the handler reaches the return. The same handler invoked from a normal plugin command (palette / hotkey) returns the correct value.

Microtasks survive, macrotasks don’t

This strongly suggests Obsidian’s CLI host captures the handler’s return Promise only up to the current microtask queue drain. Once the handler yields a macrotask (setTimeout, fetch I/O, await fs.read, …), the captured “result” is whatever was synchronously available — i.e. nothing.

await Promise.resolve() chained 100 times still works, because microtasks don’t yield the event loop.

Newer installer doesn’t help

I upgraded from an older installer to the latest 1.12.7 installer that bundles the new “significantly faster” CLI binary (per 1.12.7 changelog). Same bug.

Reporter’s comment about TTY working

I couldn’t confirm the TTY-works claim — every invocation from my terminal that uses async I/O came back empty regardless of whether I piped or not. May be worth re-verifying with setTimeout(100) specifically, with and without piping.

Workaround that works in production

Fire-and-forget. The handler must return inside the current microtask drain:

const handler: CliHandler = async (params) => {
  // Schedule the real work without awaiting it.
  void this.runHeavyWork(params).catch(() => {});
  // Return a synchronously-resolvable string.
  return JSON.stringify({ ok: true, scheduled: true });
};

Consumers then poll a read-only command (e.g. plugin:get-job-status id=...) which reads from in-memory / synchronous-disk state. Verified working across status / archive / job / sync surfaces in production agent workflows.

Impact on plugin design

This effectively forbids the natural async/await pattern for any CLI handler that does real I/O — network call, file write, IPC, Web Workers, etc. About half of my plugin’s 20 commands had to be redesigned around fire-and-forget. Worth a callout in the registerCliHandler docs at minimum, ideally a real fix in the host.

Happy to share a minimal repro plugin if useful.