Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9ff2d8e
feat: add Hermes agent provider
joeynyc May 18, 2026
82f37bd
feat: surface Hermes in web provider UI
joeynyc May 18, 2026
fbc1dd6
fix: stabilize local dev server restarts
joeynyc May 18, 2026
4fb4c16
test: cover Hermes provider integration
joeynyc May 18, 2026
2a33835
docs: mention Hermes support in README
joeynyc May 18, 2026
e518bdd
feat: smooth Hermes setup flow
joeynyc May 18, 2026
37da4ac
fix: clarify Hermes ready status
joeynyc May 18, 2026
59a16ae
feat: theme chat surface for Hermes
joeynyc May 18, 2026
3e3e214
style: refine Hermes chat surface
joeynyc May 18, 2026
b55b151
docs: expand Hermes README setup
joeynyc May 18, 2026
7191bae
docs: add Hermes themed chat screenshot
joeynyc May 18, 2026
fc8a79f
docs: refresh Hermes screenshot with fresh chat
joeynyc May 18, 2026
66d98a1
test: cover Hermes packaged setup states
joeynyc May 18, 2026
79e9c8e
feat: add Pi agent provider
joeynyc May 18, 2026
f46d704
fix: read Pi auth and default model state
joeynyc May 18, 2026
9948b09
fix: clarify Pi Codex login setup
joeynyc May 18, 2026
7a74198
fix: show unauthenticated providers in model picker
joeynyc May 18, 2026
2cf54e0
feat: add provider settings update action
joeynyc May 18, 2026
0b6010e
fix: polish Pi provider integration
joeynyc May 18, 2026
211f230
fix: clean Pi chat output and settings diagnostics
joeynyc May 18, 2026
aee7d81
fix: harden Hermes and Pi paths across platforms
joeynyc May 18, 2026
d439ab1
fix: address provider release readiness feedback
joeynyc May 18, 2026
8eff67b
polish Hermes and Pi provider setup UX
joeynyc May 19, 2026
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
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,77 @@
# T3 Code

T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, and OpenCode, more coming soon).
T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, OpenCode, Hermes, and Pi, more coming soon).

## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, and OpenCode.
> T3 Code currently supports Codex, Claude, OpenCode, Hermes, and Pi.
> Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
> - Hermes: install [Hermes Agent](https://github.com/nousresearch/hermes-agent) and run `hermes model`
> - Pi: install [Pi Agent](https://github.com/earendil-works/pi) plus `pi-acp`, then run `pi`

Hermes setup notes: [docs/providers/hermes.md](./docs/providers/hermes.md)
Pi setup notes: [docs/providers/pi.md](./docs/providers/pi.md)
Release readiness checklist: [docs/providers/release-readiness.md](./docs/providers/release-readiness.md)

## Hermes Agent support

T3 Code can run [Hermes Agent](https://github.com/nousresearch/hermes-agent) as a local ACP
provider. Enable Hermes from **Settings -> Providers**, point the binary path at your local
`hermes` executable, then select Hermes from the chat model picker.

![Hermes themed chat surface](./docs/assets/hermes-chat-theme.jpg)

Recommended macOS setup:

```bash
git clone https://github.com/nousresearch/hermes-agent.git ~/Projects/hermes-agent
cd ~/Projects/hermes-agent
python3 -m venv venv
./venv/bin/pip install -e .
mkdir -p ~/.local/bin
ln -sf ~/Projects/hermes-agent/venv/bin/hermes ~/.local/bin/hermes
~/.local/bin/hermes model
```

T3 Code auto-detects common Hermes paths such as `~/.local/bin/hermes`,
`~/Projects/hermes-agent/venv/bin/hermes`, `/opt/homebrew/bin/hermes`, and `/usr/local/bin/hermes`.
On Windows, use a full path such as `C:\Users\you\Projects\hermes-agent\venv\Scripts\hermes.exe`.
Hermes manages authentication through its own CLI and local config; T3 Code starts `hermes acp`
only when a Hermes conversation needs it.

Full setup and troubleshooting guide: [docs/providers/hermes.md](./docs/providers/hermes.md)

## Pi Agent support

T3 Code can run [Pi Agent](https://github.com/earendil-works/pi) through the
[`pi-acp`](https://github.com/svkozak/pi-acp) adapter. Enable Pi from
**Settings -> Providers**, set the ACP adapter path to `pi-acp`, set the Pi binary path to `pi` or
an absolute path, then select Pi from the chat model picker.

![Pi themed chat surface](./docs/assets/pi-chat-theme.jpg)

```bash
npm install -g @earendil-works/pi-coding-agent pi-acp
pi --version
pi-acp --help
```

T3 Code passes the configured Pi binary to the adapter with `PI_ACP_PI_COMMAND`, which keeps the
packaged desktop app working even when npm's global binary directory is not on the GUI app `PATH`.
On Windows, npm shims usually live under `C:\Users\you\AppData\Roaming\npm\pi-acp.cmd` and
`C:\Users\you\AppData\Roaming\npm\pi.cmd`.
Provider Settings also exposes a Pi update action, so users can run `pi update` from the same
place they configure the provider.

For GPT-5.5, run `pi`, use `/login`, choose ChatGPT Plus/Pro Codex, and set Pi's defaults to
`openai-codex` with `gpt-5.5`.

Full setup and troubleshooting guide: [docs/providers/pi.md](./docs/providers/pi.md)

### Run without installing

Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
],
"type": "module",
"scripts": {
"dev": "node --watch src/bin.ts",
"dev": "node scripts/dev-watch.mjs",
"build": "node scripts/cli.ts build",
"build:bundle": "tsdown",
"start": "node dist/bin.mjs",
Expand Down
94 changes: 94 additions & 0 deletions apps/server/scripts/dev-watch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { spawn } from "node:child_process";
import { watch } from "node:fs";
import { stat } from "node:fs/promises";
import { dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const scriptsDir = dirname(fileURLToPath(import.meta.url));
const serverDir = resolve(scriptsDir, "..");
const srcDir = resolve(serverDir, "src");
const entry = resolve(srcDir, "bin.ts");
const sourceExtensions = new Set([".cjs", ".cts", ".js", ".json", ".mjs", ".mts", ".ts"]);

let child;
let restartTimer;
let stopping = false;

function start() {
child = spawn(process.execPath, [entry], {
stdio: "inherit",
env: process.env,
cwd: serverDir,
});

child.on("exit", (code, signal) => {
if (stopping || restartTimer) {
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}

async function shouldRestart(filename) {
if (!filename || typeof filename !== "string") {
return false;
}

const changedPath = resolve(srcDir, filename);
if (!changedPath.startsWith(`${srcDir}/`)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev watch path check fails on Windows

Low Severity

The shouldRestart function checks changedPath.startsWith(${srcDir}/) using a forward slash, but on Windows resolve() produces backslash-separated paths. This causes the guard to always return false on Windows, meaning file changes will never trigger a server restart during development.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8eff67b. Configure here.

return false;
}

try {
const changedStat = await stat(changedPath);
if (changedStat.isDirectory()) {
return false;
}
} catch {
// Deleted source files still need a restart.
}

return sourceExtensions.has(extname(changedPath));
}

function scheduleRestart() {
clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
restartTimer = undefined;
console.log("Restarting server...");

const previous = child;
previous.once("exit", () => {
if (!stopping) {
start();
}
});
previous.kill("SIGTERM");
}, 100);
Comment on lines +58 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low scripts/dev-watch.mjs:58

If the child process exits on its own during the 100ms debounce window, the previous.once("exit", ...) listener in scheduleRestart is registered after the 'exit' event already fired. Since ChildProcess emits 'exit' only once, the listener never runs and start() is never called — the server stays dead. The parent process also won't exit because the exit handler at line 24 returns early when restartTimer is truthy. Consider checking previous.killed or previous.exitCode before registering the listener, and ensure the restart proceeds immediately if the process already exited.

  const previous = child;
+  if (previous.exitCode !== null || previous.killed) {
+    if (!stopping) {
+      start();
+    }
+  } else {
    previous.once("exit", () => {
      if (!stopping) {
        start();
      }
    });
    previous.kill("SIGTERM");
+  }
🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/scripts/dev-watch.mjs around lines 58-71:

If the child process exits on its own during the 100ms debounce window, the `previous.once("exit", ...)` listener in `scheduleRestart` is registered after the `'exit'` event already fired. Since `ChildProcess` emits `'exit'` only once, the listener never runs and `start()` is never called — the server stays dead. The parent process also won't exit because the exit handler at line 24 returns early when `restartTimer` is truthy. Consider checking `previous.killed` or `previous.exitCode` before registering the listener, and ensure the restart proceeds immediately if the process already exited.

Evidence trail:
apps/server/scripts/dev-watch.mjs lines 24-27 (exit handler returns early when `restartTimer` is truthy), lines 58-71 (`scheduleRestart` registers the `once('exit')` listener only after the debounce timeout, which may be after the child already exited). Node.js ChildProcess 'exit' event is emitted once and not replayed for late listeners. `child.kill()` on a dead process is a no-op. Verified at commit REVIEWED_COMMIT.

}

const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => {
if (await shouldRestart(filename)) {
scheduleRestart();
}
});

function shutdown(signal) {
stopping = true;
clearTimeout(restartTimer);
watcher.close();
if (!child || child.killed) {
process.exit(0);
}
child.once("exit", () => process.exit(0));
child.kill(signal);
}

process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));

start();
175 changes: 175 additions & 0 deletions apps/server/src/provider/Drivers/HermesDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* HermesDriver — `ProviderDriver` for the Hermes Agent (`hermes`) runtime.
*
* Hermes exposes an ACP-based CLI. The driver is still a plain value, but
* its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot`
* hook to run the slow ACP model-capability probe in the background without
* blocking the initial `ready`-state publish.
*
* Text generation is supported via the ACP runtime — `makeHermesTextGeneration`
* drives `runtime.prompt` with a structured-output schema and collects the
* agent's `agent_message_chunk` stream into a single JSON blob.
*
* @module provider/Drivers/HermesDriver
*/
import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import { ServerConfig } from "../../config.ts";
import { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";
import {
buildInitialHermesProviderSnapshot,
checkHermesProviderStatus,
enrichHermesSnapshot,
} from "../Layers/HermesProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
type ProviderInstance,
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
makeProviderMaintenanceCapabilities,
makeStaticProviderMaintenanceResolver,
resolveProviderMaintenanceCapabilitiesEffect,
} from "../providerMaintenance.ts";
const decodeHermesSettings = Schema.decodeSync(HermesSettings);

const DRIVER_KIND = ProviderDriverKind.make("hermes");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
const UPDATE = makeStaticProviderMaintenanceResolver(
makeProviderMaintenanceCapabilities({
provider: DRIVER_KIND,
packageName: null,
updateExecutable: "hermes",
updateArgs: ["update"],
updateLockKey: "hermes-agent",
}),
);

export type HermesDriverEnv =
| ChildProcessSpawner.ChildProcessSpawner
| FileSystem.FileSystem
| HttpClient.HttpClient
| Path.Path
| ProviderEventLoggers
| ServerConfig;

const withInstanceIdentity =
(input: {
readonly instanceId: ProviderInstance["instanceId"];
readonly displayName: string | undefined;
readonly accentColor: string | undefined;
readonly continuationGroupKey: string;
}) =>
(snapshot: ServerProviderDraft): ServerProvider => ({
...snapshot,
instanceId: input.instanceId,
driver: DRIVER_KIND,
...(input.displayName ? { displayName: input.displayName } : {}),
...(input.accentColor ? { accentColor: input.accentColor } : {}),
continuation: { groupKey: input.continuationGroupKey },
});

export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = {
driverKind: DRIVER_KIND,
metadata: {
displayName: "Hermes",
supportsMultipleInstances: true,
},
configSchema: HermesSettings,
defaultConfig: (): HermesSettings => decodeHermesSettings({}),
create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const httpClient = yield* HttpClient.HttpClient;
const eventLoggers = yield* ProviderEventLoggers;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
instanceId,
});
const stampIdentity = withInstanceIdentity({
instanceId,
displayName,
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies HermesSettings;
const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: processEnv,
});

const adapter = yield* makeHermesAdapter(effectiveConfig, {
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
instanceId,
});
const textGeneration = yield* makeHermesTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkHermesProviderStatus(effectiveConfig, processEnv).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);

const snapshot = yield* makeManagedServerProvider<HermesSettings>({
maintenanceCapabilities,
getSettings: Effect.succeed(effectiveConfig),
streamSettings: Stream.never,
haveSettingsChanged: () => false,
initialSnapshot: (settings) =>
buildInitialHermesProviderSnapshot(settings).pipe(Effect.map(stampIdentity)),
checkProvider,
// Keep version-advisory enrichment off the initial provider snapshot
// so Hermes never blocks server startup or settings rendering.
enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
enrichHermesSnapshot({
snapshot: currentSnapshot,
maintenanceCapabilities,
publishSnapshot,
stampIdentity,
httpClient,
}),
refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
}).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to build Hermes snapshot: ${cause.message ?? String(cause)}`,
cause,
}),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
continuationIdentity,
displayName,
accentColor,
enabled,
snapshot,
adapter,
textGeneration,
} satisfies ProviderInstance;
}),
};
Loading
Loading