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
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,38 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 26.4.0
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.0
- name: Native OpenTUI test
run: npm run test:cli-opentui
- name: Package consumer compatibility
run: npm run test:package-consumer
- name: Test
run: npm test -- --runInBand --forceExit
yarn-lock:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 26.4.0
- name: Install Yarn
run: npm install --global yarn@1.22.22
- name: Install dependencies from the Yarn lockfile
run: yarn install --frozen-lockfile --ignore-scripts --non-interactive
2 changes: 1 addition & 1 deletion .github/workflows/update-homebrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 26.4.0

- name: Check out Homebrew tap
uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26.4.0
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Thank you for helping improve Iris Agent.

Requirements:

- Node.js `>=22.13.0`
- Node.js `>=26.4.0`
- npm or Yarn

```sh
Expand Down
4 changes: 2 additions & 2 deletions Formula/iris-agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ class IrisAgent < Formula
sha256 "8e2fb39f2cc081feabf6303963157111088adb7dbc8f526c278d0aaedc42a085"
license "MIT"

depends_on "node@22"
depends_on "node"

def install
ENV["PATH"] = "#{formula_opt_bin("node@22")}:#{ENV["PATH"]}"
ENV["PATH"] = "#{formula_opt_bin("node")}:#{ENV["PATH"]}"

system "npm", "install", *std_npm_args
bin.install_symlink libexec/"bin/iris-agent"
Expand Down
96 changes: 93 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ It provides streaming chat, workspace tools, LSP routes, MCP integration, comman
- **CLI** - Interactive chat in the terminal
- **ACP Server** - Agent Client Protocol via stdio for seamless IDE integration

The SDK and ACP server require Node.js `>=22.13.0`. `IrisClient` is a
Iris Agent requires Node.js `>=26.4.0`. `IrisClient` is a
Node.js API for IDE desktop or backend processes; it is not intended to run in
a browser renderer.

Expand Down Expand Up @@ -137,6 +137,95 @@ OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat --mode
```

This starts an interactive streaming chat session in your terminal with access to the workspace and tools.
OpenTUI is selected automatically on supported interactive terminals. With
Node.js >=26.4.0, enable FFI to use it with the same npm launcher:

```sh
# With OPENROUTER_API_KEY already configured in your terminal
NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat

# Require OpenTUI rather than allowing a fallback to plain chat
NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat --chat-ui opentui
```

Keep `NODE_OPTIONS="--experimental-ffi"` scoped to the Node.js OpenTUI launch
command rather than exporting it globally. It is not needed for Bun, plain chat,
the HTTP service or ACP, and older Node.js versions may reject the flag.

Node.js and npm must be on your `PATH`. For a Homebrew Node installation on
Apple Silicon macOS, use `export PATH="/opt/homebrew/bin:$PATH"` if needed.

#### OpenTUI terminal chat

Interactive chat uses `@opentui/core` directly, without a React renderer. On a
supported runtime, `--chat` opens a scrollable transcript with streaming
Markdown, tool activity and a multiline message editor. The existing memory
thread, prompt budgeting and workspace tools are reused.

OpenTUI 0.5.14 requires **Bun >=1.3.0** or **Node.js >=26.4.0 with
`--experimental-ffi`**. The HTTP service, SDK, ACP server and plain chat also
require Node.js >=26.4.0. The OpenTUI stack is optional, so it can be omitted
when building or installing without using the native UI.
Keep optional dependencies enabled to use OpenTUI, including its native library
for the host platform. Build release packages with Node.js >=26.4.0 and the
optional dependencies installed so the compiled OpenTUI interface is included.
Source builds without those dependencies explicitly skip the native UI while
still compiling the SDK, service, ACP and plain chat.

```sh
# Source checkout, using Bun
OPENROUTER_API_KEY=... npm run cli:tui -- --workspace /path/to/project

# Source checkout, using Node.js >=26.4.0
OPENROUTER_API_KEY=... node --experimental-ffi --import tsx cli.ts --chat --workspace /path/to/project

# Compiled CLI, using Bun
OPENROUTER_API_KEY=... bun dist/cli.js --chat --workspace /path/to/project

# Compiled CLI, using Node.js >=26.4.0
OPENROUTER_API_KEY=... node --experimental-ffi dist/cli.js --chat --workspace /path/to/project

# Explicitly request OpenTUI, or use the plain interface
bun dist/cli.js --chat --chat-ui opentui
iris-agent --chat --chat-ui plain
```

`--chat-ui auto` is the default: it prefers OpenTUI on supported interactive
terminals and selects plain chat for redirected input or output, CI, dumb
terminals or unsupported runtimes. If native UI initialization
fails, auto restores the terminal and falls back to plain chat. Explicit
`--chat-ui opentui` reports the initialization error instead.

| Key | Action |
| --- | --- |
| Enter | Send a message |
| Shift+Enter, Alt+Enter or Ctrl+J | Insert a newline; Ctrl+J works in terminals that cannot distinguish Shift+Enter |
| Esc | Cancel the current request |
| Ctrl+C | Cancel a running request, or quit while idle |
| Ctrl+D | Quit while idle with an empty editor |
| Page Up / Page Down or mouse wheel | Scroll the transcript |
| Ctrl+L | Toggle captured agent logs |
| `exit` or `quit` | End the session |

The editor remains available for drafting during a response; sending is disabled
until that response finishes or cancellation settles. Cancellation passes an
abort signal to the agent and cancels the active stream; a tool that does not
honor cancellation may still complete its work. Tool results requiring approval
are labeled accordingly; this UI preserves the existing CLI execution policy.

Validate the native editor and rendering without provider credentials:

```sh
npm run test:cli-opentui
```

Validate compilation and plain chat without the optional UI dependencies:

```sh
npm run test:cli-optional
# On Node.js >=26.4.0 with Yarn 1 available, also verify Yarn installation
npm run test:cli-optional -- --check-yarn-install
```

### ACP Server

Expand Down Expand Up @@ -210,7 +299,7 @@ iris-agent --chat
OPENROUTER_API_KEY=... iris-agent --acp
```

The Homebrew formula installs Node.js 22 and keeps Iris Agent and its
The Homebrew formula installs Node.js and keeps Iris Agent and its
dependencies under Homebrew's managed prefix. Upgrade it with:

```sh
Expand All @@ -229,6 +318,7 @@ the repository's **Actions** tab. It requires a repository secret named
- `--workspace` (`-w`) - Path to the workspace/project root; defaults to the current working directory
- `--acp` (`-a`) - Start ACP protocol server (stdio-based)
- `--chat` (`-c`) - Start interactive chat mode
- `--chat-ui` - Select `auto` (default), `opentui`, or `plain`
- `--modelId` - Model identifier used for chat/ACP sessions (default: `openrouter/openai/gpt-5.3-codex` or `MODEL_ID` / `OPENROUTER_MODEL` env vars)

Running the CLI without `--chat` or `--acp` prints help.
Expand Down Expand Up @@ -844,7 +934,7 @@ NPM_CONFIG_OTP=<code> npm run release:publish

`npm run release` requires a clean worktree, runs type checking, tests, build,
and `npm pack --dry-run`. `npm run release:publish` performs the same checks
before publishing with public npm access. Use Node.js `>=22.13.0`, matching the
before publishing with public npm access. Use Node.js `>=26.4.0`, matching the
package engine requirement.

The repository also provides Make targets:
Expand Down
138 changes: 138 additions & 0 deletions api/core/library/cliChatTurn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { resolveToolExecutionStatus } from "../agent/utils/toolLifecycle";
import type { CliChatUi } from "./cliChatUi";

type CliAgentChunk = { type: string; payload?: Record<string, unknown> };
export interface CliChatAgent {
stream?: (
prompt: string,
options: Record<string, unknown>,
) => Promise<{
fullStream: ReadableStream<CliAgentChunk>;
text?: Promise<string> | string;
}>;
generate?: (
prompt: string,
options: Record<string, unknown>,
) => Promise<unknown>;
}

/** Consume the existing agent protocol; the UI never owns agent execution. */
export const runCliChatTurn = async (
agent: CliChatAgent,
prompt: string,
options: Record<string, unknown>,
ui: CliChatUi,
signal: AbortSignal,
): Promise<void> => {
signal.throwIfAborted();
ui.setStatus("Thinking...");
const turnOptions = { ...options, abortSignal: signal };
if (typeof agent.stream !== "function") {
if (typeof agent.generate !== "function") {
throw new Error("Agent does not support streaming or text generation");
}
const result = await agent.generate(prompt, turnOptions);
signal.throwIfAborted();
const text =
typeof result === "string"
? result
: result && typeof result === "object" && "text" in result
? String(result.text || "")
: JSON.stringify(result, null, 2) || "";
ui.appendText(text);
return;
}

const streamResult = await agent.stream(prompt, turnOptions);
const reader = streamResult.fullStream.getReader();
const cancelReader = () => {
void reader.cancel().catch(() => {});
};
signal.addEventListener("abort", cancelReader, { once: true });
let drained = false;
let hasOutput = false;
const pendingIds = new Map<string, number>();
let anonymousCalls = 0;
const pendingCount = () =>
[...pendingIds.values()].reduce((total, count) => total + count, 0) +
anonymousCalls;

try {
if (signal.aborted) cancelReader();
signal.throwIfAborted();
while (true) {
const { done, value } = await reader.read();
signal.throwIfAborted();
if (done) {
drained = true;
break;
}
const payload = value?.payload || {};
if (value?.type === "text-delta") {
const text = typeof payload.text === "string" ? payload.text : "";
if (text) {
hasOutput = true;
ui.appendText(text);
if (!pendingCount()) ui.setStatus("Responding...");
}
} else if (value?.type === "tool-call") {
const name =
typeof payload.toolName === "string" ? payload.toolName : "tool";
const id =
typeof payload.toolCallId === "string"
? payload.toolCallId
: undefined;
if (id) pendingIds.set(id, (pendingIds.get(id) || 0) + 1);
else anonymousCalls++;
ui.toolCall(name, id);
ui.setStatus(
pendingCount() > 1 ? "Running tools..." : `Running ${name}...`,
);
} else if (
value?.type === "tool-result" ||
value?.type === "tool-error"
) {
const id =
typeof payload.toolCallId === "string"
? payload.toolCallId
: undefined;
const name =
typeof payload.toolName === "string" ? payload.toolName : undefined;
const status =
value.type === "tool-error"
? "failed"
: resolveToolExecutionStatus(
payload.result ??
payload.output ??
payload.content ??
payload.data,
);
ui.toolResult(status, id, name);
if (status !== "pending" && status !== "in_progress") {
if (id) {
const count = pendingIds.get(id) || 0;
if (count > 1) pendingIds.set(id, count - 1);
else pendingIds.delete(id);
} else if (anonymousCalls) anonymousCalls--;
if (!pendingCount()) ui.setStatus("Thinking...");
}
} else if (value?.type === "error") {
const error = payload.error ?? payload.message;
throw error && typeof error === "object"
? error
: new Error(
typeof error === "string" ? error : "Agent stream failed",
);
}
}
if (!hasOutput && streamResult.text) {
const text = await streamResult.text;
signal.throwIfAborted();
if (text) ui.appendText(text);
}
} finally {
signal.removeEventListener("abort", cancelReader);
if (!drained) await reader.cancel().catch(() => {});
reader.releaseLock();
}
};
Loading
Loading