npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gobing-ai/ts-ai-runner

v0.5.11

Published

@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.

Readme

@gobing-ai/ts-ai-runner

Coding-agent command shims, installation detection, doctor checks, slash-command translation, identity preamble construction, and team-mode orchestration for downstream CLIs.

Installation

bun add @gobing-ai/ts-ai-runner

What It Provides

ts-ai-runner normalizes the command-line surface of common coding agents so application code can work with stable TypeScript APIs instead of hard-coded executable arguments.

| Export | Purpose | | -------- | --------- | | AiRunner | Runs help, version, auth, prompt, and slash commands through a pluggable process executor; can also build a prompt command without executing it. Emits typed events via optional EventBus<AgentEvents>. | | AgentDetector | Probes supported agent CLIs and parses version output | | DoctorRunner | Combines installation and authentication checks into a usability report | | getAgentShim() | Returns the pure command builder for one supported agent (resolves aliases to canonical) | | resolveAgentName() | Maps a canonical id or alias to its canonical AgentName; warns on deprecated/alias ids | | buildAgentCommand() | Shared command-build seam (identity preamble + shim dispatch) used by both one-shot and team paths | | translateSlashCommand() | Converts Claude-style /plugin:command inputs to each agent's dialect | | isClaudeStyleSlashCommand() | Tests whether input matches the /plugin:command pattern | | buildIdentityPreamble() / getGitContext() | Builds team-mode identity, communication context, and git metadata for prompts | | loadAgentSpecs() / saveAgentSpec() / deleteAgentSpec() | Persist agent definitions as YAML-compatible config | | validateAgentId() | Enforces agent ID format rules | | formatMessage() | Renders a DrainedMessage into the line injected into an agent's stdin pipe | | MessageStore / DrainedMessage | ai-runner-owned orchestration persistence port and minimal message view consumed by TeamOrchestrator | | TeamAgentProcess | Manages a long-running agent subprocess with pipe-mode stdin/stdout | | TeamOrchestrator | Loads specs, starts/stops agents, routes durable/live messages, and emits lifecycle events | | AgentEvents / AiRunnerProcessEvents | Typed event maps for agent and process-level observability | | AGENT_SHIMS / TIER1_PRIORITY / TIER2_AGENTS / DISPLAY_ORDER | Agent registry constants | | isAgentName() | Type guard for supported agent identifiers | | createDecisionMaker() / q | Batch ask and single-question choice/score/noul decisions with provider-neutral question/answer types and a DecisionError taxonomy |

Supported agent identifiers: claude, codex, gemini (deprecated), pi, omp, opencode, antigravity-cli, openclaw, hermes, grok, deepseek, fm (text-only). The antigravity id is a deprecated alias of antigravity-cli. See Deprecation & Aliases.

Architecture

graph TB
    subgraph Shared ["Shared components"]
        AgentShim["AgentShim<br/>(pure command builder per agent)"]
        Identity["buildIdentityPreamble()<br/>(team-mode identity context)"]
        EventBus["EventBus&lt;AgentEvents&gt;<br/>(opt-in observability)"]
        ProcessExecutor["ProcessExecutor<br/>(injectable, ts-runtime)"]
    end

    subgraph OneShot ["One-shot mode"]
        AiRunner["AiRunner<br/>runPromptCommand / runSlashCommand<br/>buildPromptCommand / runHelpCommand<br/>runVersionCommand / runAuthCommand"]
        AgentDetector["AgentDetector<br/>(probes CLIs via AiRunner)"]
        DoctorRunner["DoctorRunner<br/>(install + auth health checks)"]

        AiRunner -->|"resolves command via"| AgentShim
        AiRunner -->|"delegates execution to"| ProcessExecutor
        AgentDetector -->|"probes versions via"| AiRunner
        DoctorRunner -->|"probes install + auth via"| AiRunner
        AiRunner -.->|"emits events"| EventBus
        AiRunner -.->|"enriches prompt"| Identity
    end

    subgraph TeamMode ["Team mode (building blocks)"]
        TeamOrchestrator["TeamOrchestrator<br/>loadSpecs / startAgent / stopAgent<br/>sendMessage / getAgentStatus / stopAll"]
        AgentSpec["AgentSpec<br/>(YAML config)<br/>load / save / delete"]
        TeamAgentProcess["TeamAgentProcess<br/>(pipe-mode subprocess)<br/>start / stop / send / subscribe"]
        MessageStore["<b>MessageStore</b><br/>(ai-runner-owned port)<br/>enqueue / drainPending / markDelivered / markFailed"]
        InboxMessageDao["<i>InboxMessageDao<br/>(@gobing-ai/ts-db/inbox)</i><br/>structural provider"]

        TeamOrchestrator -->|"loads"| AgentSpec
        TeamOrchestrator -->|"creates + manages"| TeamAgentProcess
        TeamOrchestrator -->|"writes/reads via"| MessageStore
        InboxMessageDao -.->|"satisfies"| MessageStore
        TeamOrchestrator -->|"resolves command via"| AgentShim
        TeamOrchestrator -.->|"emits events"| EventBus
        TeamOrchestrator -.->|"builds preamble"| Identity
        TeamAgentProcess -->|"spawns via"| ProcessExecutor
    end

One-shot prompt flow

sequenceDiagram
    participant Caller
    participant AiRunner
    participant AgentShim
    participant Identity as buildIdentityPreamble
    participant Slash as translateSlashCommand
    participant Executor as ProcessExecutor
    participant Agent as Coding Agent (CLI)
    participant EventBus

    Caller->>AiRunner: runPromptCommand("codex", options)
    AiRunner->>AiRunner: hasIdentityOptions(options)?
    alt has team-mode fields
        AiRunner->>Identity: buildIdentityPreamble({agentId, peers, ...})
        Identity-->>AiRunner: preamble text
        AiRunner->>AiRunner: prepend preamble to input
    end
    AiRunner->>AgentShim: getPromptCommand(enrichedOptions)
    AgentShim-->>AiRunner: { command, args }
    AiRunner->>Executor: run({ command, args, cwd, timeout })
    Executor->>Agent: spawn CLI subprocess
    Executor->>AiRunner: onSpawn(pid)
    AiRunner->>EventBus: emit("agent.invoke.start")
    Agent-->>Executor: stdout / stderr / exit
    Executor-->>AiRunner: ProcessResult { exitCode, stdout, stderr, durationMs }
    AiRunner->>EventBus: emit("agent.invoke.exit")
    AiRunner-->>Caller: AgentRunResult

runSlashCommand() follows the same path with an extra step: it calls translateSlashCommand() to convert the slash input to the target agent's dialect before delegating to runPromptCommand().

Team mode lifecycle

sequenceDiagram
    participant Host as Host App
    participant Orch as TeamOrchestrator
    participant Spec as AgentSpec (filesystem)
    participant Shim as AgentShim
    participant Identity as buildIdentityPreamble
    participant Proc as TeamAgentProcess
    participant DB as InboxMessageDao
    participant Agent as Coding Agent (CLI)
    participant EventBus

    Note over Host,EventBus: Starting an agent
    Host->>Orch: startAgent("coder")
    Orch->>Spec: loadAgentSpecs(configDir)
    Spec-->>Orch: AgentSpec[]
    Orch->>Orch: requireSpec("coder") → spec
    Orch->>Orch: getPeerSpecs(workspace, "coder") → peers
    Orch->>Identity: buildIdentityPreamble({agentId, peers, ...})
    Identity-->>Orch: preamble
    Orch->>Shim: getPromptCommand({input: preamble, ...})
    Shim-->>Orch: { command, args }
    Orch->>Proc: new TeamAgentProcess({spec, command})
    Orch->>Proc: start()
    Proc->>Agent: spawn pipe-mode subprocess
    Agent-->>Proc: stdout/stderr streams
    Orch->>DB: drainPending("coder")
    DB-->>Orch: pending messages
    alt pending messages exist
        loop for each message
            Orch->>Proc: send(formattedMessage)
            Proc->>Agent: write to stdin
            Orch->>DB: markDelivered(msg.id)
        end
    end
    Orch->>EventBus: emit("agent.started")
    Orch-->>Host: TeamAgentProcess

    Note over Host,EventBus: Sending a message (durable + live)
    Host->>Orch: sendMessage(null, "coder", "Implement task 0005")
    Orch->>DB: enqueue(null, "coder", body)
    DB-->>Orch: messageId
    alt agent is running
        Orch->>DB: drainPending("coder")
        DB-->>Orch: pending messages
        loop for each message
            Orch->>Proc: send(formattedMessage)
            Proc->>Agent: write to stdin
            Orch->>DB: markDelivered(msg.id)
        end
    end
    Orch->>EventBus: emit("agent.message.sent")
    Orch-->>Host: messageId

    Note over Host,EventBus: Stopping an agent
    Host->>Orch: stopAgent("coder")
    Orch->>Proc: stop()
    Proc->>Agent: SIGTERM / kill
    Orch->>Orch: running.delete("coder")
    Orch->>EventBus: emit("agent.stopped")
    Orch-->>Host: void

Key design decisions:

  • Shims are pure: AgentShim produces { command, args } without touching the filesystem or launching processes. All side effects live in AiRunner and TeamAgentProcess.
  • ProcessExecutor is injectable: tests inject a stub executor; production uses NodeProcessExecutor (or the Bun pipe-process seam for team mode).
  • Events are opt-in: AiRunnerOptions.events and TeamOrchestratorOptions.events accept an EventBus<AgentEvents> for structured observability. Per-run AgentRunOptions.onOutput forwards incremental stdout/stderr without losing the buffered result, and correlation carries application-owned runId, executionId, and optional actionId unchanged on invocation start/exit events. Without observers, the runner is silent.
  • Team mode is composable: AgentSpec, TeamAgentProcess, and TeamOrchestrator are small building blocks. TeamOrchestrator depends on the ai-runner-owned MessageStore port; InboxMessageDao from @gobing-ai/ts-db/inbox is one structural provider — no adapter class — and in-memory test doubles implement the port directly. Downstream apps compose them into their own orchestration layer.

The package depends on @gobing-ai/ts-runtime for process execution and @gobing-ai/ts-infra for structured logging and EventBus. @gobing-ai/ts-db is a development dependency only: production source under packages/ai-runner/src has no direct @gobing-ai/ts-db imports for message access, and only the DB-backed integration test resolves the concrete InboxMessageDao. The target agent CLIs are not bundled; install them separately in the host environment.

Detect Installed Agents

import { AgentDetector } from '@gobing-ai/ts-ai-runner';

const detector = new AgentDetector({ timeout: 5_000 });
const agents = await detector.detectAll();

for (const agent of agents) {
    console.log(agent.name, agent.installed, agent.version, agent.error);
}

Probe a single agent when you already know the target:

const codex = await detector.detectOne('codex');
if (!codex.installed) {
    throw new Error(codex.error ?? 'codex is not installed');
}

Unknown agent names are reported as unavailable rather than throwing.

Run Agent Commands

import { AiRunner } from '@gobing-ai/ts-ai-runner';

const runner = new AiRunner({
    defaultCwd: '/workspace/project',
    defaultTimeout: 60_000,
});

const result = await runner.runPromptCommand('codex', {
    input: 'Review packages/runtime/src/fs.ts',
    model: 'gpt-5',
    mode: 'text',
});

if (result.exitCode !== 0) {
    throw new Error(result.stderr);
}

console.log(result.stdout);

AiRunner captures stdout, stderr, exitCode, optional termination signal, and durationMs. It does not throw on non-zero agent exits; callers decide how to handle failures.

Every invocation is logged through an injectable logger (getLogger('ai-runner') by default): one debug line per dispatch and an error line on any non-zero exit. Pass a custom logger to the constructor to route diagnostics elsewhere or silence them in tests.

Slash commands and command preview

runSlashCommand() translates a Claude-style /plugin:command args input into the target agent's dialect (via translateSlashCommand()) and dispatches it as a prompt. Non-slash input passes through unchanged.

// For codex, "/review:pr 123" becomes "$review-pr 123" before dispatch.
await runner.runSlashCommand('codex', '/review:pr 123', { model: 'gpt-5' });

buildPromptCommand() returns the resolved { command, args } without executing it — useful for previewing, logging, or dry-running the exact argv a prompt would dispatch. It applies the same identity-preamble enrichment as runPromptCommand().

const { command, args } = runner.buildPromptCommand('pi', { input: 'ship it', mode: 'json' });
// command === 'pi', args === ['--no-session', '-p', 'ship it', '--mode', 'json']

Team identity preambles

PromptOptions accepts optional team-mode fields. When any of purpose, systemPrompt, taskId, or non-empty peers is supplied, AiRunner prepends an identity preamble before dispatching the prompt through the selected agent shim.

await runner.runPromptCommand('codex', {
    input: 'Implement the inbox DAO tests',
    taskId: '0005',
    purpose: 'Implement scoped code changes',
    systemPrompt: 'Follow repository AGENTS.md rules.',
    peers: [{ id: 'planner', type: 'claude', purpose: 'Plan implementation work' }],
});

Use buildIdentityPreamble() directly when a host app needs to preview or inject the same context outside AiRunner:

import { buildIdentityPreamble } from '@gobing-ai/ts-ai-runner';

const preamble = buildIdentityPreamble({
    agentId: 'coder',
    agentType: 'codex',
    workspace: '/workspace/spur',
    taskId: '0005',
    taskTitle: 'Implement team mode primitives',
    purpose: 'Make focused code changes',
    peers: [{ id: 'reviewer', type: 'claude', purpose: 'Review correctness and risk' }],
    guardrails: ['Do not commit without operator approval.'],
});

Use getGitContext() to auto-detect the current branch and dirty state:

import { getGitContext } from '@gobing-ai/ts-ai-runner';

const gitBlock = await getGitContext('/workspace/spur');
// "Git context:\nbranch: feat/team-mode\ndirty: 3 files"

getGitContext is async (returns Promise<string | null>) and defaults to the canonical ProcessExecutor from @gobing-ai/ts-runtime. A deprecated sync escape hatch getGitContextSync remains for one release for sync callers.

Session Affinity (run-scoped sessions)

PromptOptions carries two additive fields for pinning or isolating sessions:

  • sessionId?: string — resume a specific prior session when the agent supports resume-by-id.
  • sessionDir?: string — isolate session storage into a caller-owned directory (durable).

When either is set, the shim selects the run-scoped session path and never emits an unscoped global continue/last-session flag. This prevents a pipeline hop from attaching to the host's interactive session (ADR-047).

Precedence (R5):

  1. sessionId or sessionDir set → pin/isolate path; bare continue is ignored for global-last.
  2. else continue === true → legacy resume-last (only where the agent allows it).
  3. else → fresh open.

Capability query (R2): consult getAgentSessionCapability(agent) rather than inventing per-agent argv. It reports supportsResumeById, supportsSessionDir, supportsPersistentStdin, supportsStructuredOutput, the verifiedAgainst CLI version the row was probed on, and a note naming the reason for any false (including CLI flags that exist but are not shim-wired yet). Uninstalled CLIs carry verifiedAgainst: 'unverified …' — never a fabricated version.

import { getAgentSessionCapability, getAgentShim } from '@gobing-ai/ts-ai-runner';

const cap = getAgentSessionCapability('omp'); // { supportsResumeById: true, supportsSessionDir: true, supportsPersistentStdin: true, … }
const { args } = getAgentShim('omp').getPromptCommand({
    input: '',
    sessionDir: '.spur/run/r1/agent-sessions/omp',
    sessionId: 'abc123',
});
// args: ['-p', '', '--session-dir', '.spur/run/r1/...', '-r', 'abc123', '--mode', 'text']

Shim / capability matrix (probed 2026-09-18; verifiedAgainst per row in getAgentSessionCapability):

| Agent | resumeById | sessionDir | sessionId resume flag | persistentStdin | structuredOutput | | ----- | ---------- | ---------- | --------------------- | --------------- | ---------------- | | omp | ✓ | ✓ | -r <id> | ✓ (--mode rpc) | ✓ (--mode json) | | pi | ✓ | ✓ | -r <id> | ✓ (--mode rpc) | ✓ (--mode json) | | claude | ✓ | ✗ (ignored) | --resume <id> | ✓ (--input-format stream-json; not shim-wired) | ✓ (--output-format json) | | codex | ✓ (0.154.0) | ✗ (ignored) | exec resume <id> <prompt> | ✗ | ✓ (--json) | | agy | ✓ | ✗ (ignored) | --conversation <id> | ✗ | ✗ (unverified) | | grok | ✓ | ✗ (ignored) | --resume <id> | ✗ | ✓ (--output-format json) | | gemini | ✗ (-r = latest/index) | ✗ | degrade → fresh -p | ✗ | ✓ (-o json) | | opencode | ✗ (CLI -s <id> unwired) | ✗ | degrade → fresh run | ✗ | ✓ (--format json) | | openclaw | ✗ (CLI --session-id unwired) | ✗ | degrade → fresh agent | ✗ | ✓ (CLI --json; unwired) | | hermes | ✗ (unverified) | ✗ | degrade → fresh chat | ✗ | ✗ (unverified) | | deepseek | ✗ | ✗ | degrade → fresh headless one-shot | ✗ | ✗ | | fm | ✓ | ✓ | --resume <file> + --save-transcript <file> (file = <sessionDir>/<sessionId>.json) | ✗ (fm chat is interactive only) | ✗ (--schema needs a file; ts-decision-fm surface) |

Claude session discovery: claude has no session-dir flag and does not accept a caller-chosen session id at open; the headless run's session id is discovered from the process output after the run (the discoverSessionId contract consumers implement). The capability row encodes exactly that: supportsResumeById: true (resume later via --resume <id>), supportsSessionDir: false.

Degrade rule: when sessionDir/sessionId is set and the agent lacks resume-by-id or a session-store flag, the shim opens fresh in isolation (or plain fresh) — never bare global continue/last. codex pins via non-interactive exec resume <id> <prompt> (verified 0.154.0); a sessionDir-only request still degrades to a fresh exec (no session-dir flag).

Durable vs ephemeral (R4): for omp/pi, setting sessionDir implies a durable session — the shim omits --no-session so a session file is written and discoverable. The legacy fresh path (no session fields, continue false) keeps --no-session.

Observability

AiRunner and TeamOrchestrator emit typed events when an EventBus<AgentEvents> is provided:

import { AiRunner } from '@gobing-ai/ts-ai-runner';
import { EventBus } from '@gobing-ai/ts-infra';
import type { AgentEvents } from '@gobing-ai/ts-ai-runner';

const bus = new EventBus<AgentEvents>();
bus.on('agent.invoke.start', (data) => console.log('starting', data.label));
bus.on('agent.invoke.exit', (data) => console.log('done', data.label, data.exitCode, data.durationMs));

const runner = new AiRunner({ events: bus });

Available events:

| Event | When | | ------- | ------ | | agent.invoke.start | After the process executor confirms spawn; absent on pre-spawn failure | | agent.invoke.exit | After an agent CLI invocation exits | | agent.started | When a long-running team agent process starts | | agent.stopped | When a long-running team agent process stops | | agent.message.sent | When a message is sent to a team agent process |

AiRunnerOptions.processEvents accepts a separate EventBus<AiRunnerProcessEvents> for process-level events from the underlying executor.

Inject a Process Executor

For tests, dry runs, or sandboxed launchers, inject a ProcessExecutor from @gobing-ai/ts-runtime:

import type { ProcessExecutor } from '@gobing-ai/ts-runtime';
import { AiRunner } from '@gobing-ai/ts-ai-runner';

const processExecutor: ProcessExecutor = {
    async run(options) {
        return {
            exitCode: 0,
            stdout: `${options.command} ${options.args.join(' ')}`,
            stderr: '',
            durationMs: 1,
        };
    },
};

const runner = new AiRunner({ processExecutor });

Doctor Checks

DoctorRunner verifies both installation and authentication state. Auth checks use each agent's native command when available, and known credential files or environment variables when the CLI has no auth-status command.

import { DoctorRunner } from '@gobing-ai/ts-ai-runner';

const doctor = new DoctorRunner();
const report = await doctor.runAll();

const usable = report.filter((agent) => agent.usable);

Each result includes:

interface DoctorResult {
    agent: string;
    installed: boolean;
    version: string | null;
    authenticated: boolean;
    usable: boolean;
    tier: 1 | 2;
    channels: string[];
    error: string | null;
}

Tier 1 agents support direct prompt-style CLI execution. Tier 2 agents are gateway or TUI constrained and may require adapter logic in the downstream app.

Slash Command Translation

Claude-style plugin commands use /plugin:command args. Other agents expose different command syntaxes. Use translateSlashCommand() before sending user-entered slash commands to a target agent:

import { translateSlashCommand } from '@gobing-ai/ts-ai-runner';

translateSlashCommand('claude', '/rd3:dev-fixall bun run check');
// /rd3:dev-fixall bun run check

translateSlashCommand('codex', '/rd3:dev-fixall bun run check');
// $rd3-dev-fixall bun run check

translateSlashCommand('pi', '/rd3:dev-fixall bun run check');
// /skill:rd3-dev-fixall bun run check

Non-slash input is returned unchanged. Use isClaudeStyleSlashCommand() to test input before translation.

Command Shims

If you need to inspect command construction without launching a process, use the pure shim API:

import { getAgentShim } from '@gobing-ai/ts-ai-runner';

const shim = getAgentShim('codex');
const command = shim.getPromptCommand({ input: 'Summarize this repository' });

console.log(command.command, command.args);

Each shim implements the AgentShim interface:

interface AgentShim {
    readonly name: AgentName;
    readonly command: string;
    readonly tier: 1 | 2;
    readonly aliases?: readonly string[];
    readonly deprecated?: { readonly since: string; readonly replacedBy?: AgentName };
    getHelpCommand(): ShimCommand;
    getVersionCommand(): ShimCommand;
    getPromptCommand(options: PromptOptions): ShimCommand;
    getAuthCommand(): ShimCommand | null;
}

Agent-specific behavior:

| Agent | CLI | Tier | Auth check | Prompt flags | | ------- | ----- | ------ | ------------ | -------------- | | claude | claude | 1 | claude auth status | -p, --continue, --model, --output-format | | codex | codex | 1 | codex login status | exec <prompt>, exec resume --last, -m, --json | | gemini (deprecated) | gemini | 1 | env-only | -p, -r latest (resume), -m, -o | | pi | pi | 1 | pi --list-models | --no-session, -p, -c (resume), --model, --mode | | omp | omp | 1 | omp --list-models | --no-session, -p, -c (resume), --model, --mode | | opencode | opencode | 1 | opencode providers | run, -c, -m, --format json | | antigravity-cli | agy | 1 | env-only | -p, --continue, --model | | openclaw | openclaw | 2 | openclaw health | agent --local -m | | hermes | hermes | 1 | hermes doctor | chat -q, --continue, -m | | grok | grok | 1 | env/file (XAI_API_KEY or ~/.grok/auth.json) | -p, -c (resume), -m, --output-format plain\|json (maps ai-runner text → plain) | | deepseek | dsh | 1 | env-only / ~/.dsh (no status verb) | --profile headless <task>; session/model options degrade to a fresh one-shot (no flags at 0.1.5-rc.1) | | fm | fm | 1 | fm available --model system | respond --no-stream, -m, transcript session flags; text-only (no file/shell tools — excluded from TIER1_PRIORITY auto-selection) |

This is the right layer for UI previews, audit logging, and custom launchers.

Deprecation & Aliases

The registry carries lifecycle metadata so agent ids can be retired without breaking existing callers.

resolveAgentName(input) maps any canonical id or alias to its canonical AgentName. Resolving a deprecated or aliased id emits exactly one warn through the logger seam and never throws.

import { resolveAgentName } from '@gobing-ai/ts-ai-runner';

resolveAgentName('antigravity');     // → 'antigravity-cli' (alias), warns
resolveAgentName('gemini');          // → 'gemini' (deprecated canonical), warns
resolveAgentName('antigravity-cli'); // → 'antigravity-cli' (canonical, no warn)
resolveAgentName('cursor');          // → undefined

Current deprecation map:

| Id | Status | Canonical | Notes | | ---- | -------- | ----------- | ------- | | antigravity | alias of antigravity-cli | antigravity-cli | Old tier-2 id; both use binary agy. Resolving warns. | | gemini | deprecated | gemini (self) → replaced by antigravity-cli | Gemini CLI sunset 2026-06-18. Shim stays functional. | | omp | canonical | omp | First-class; NOT a pi alias. | | hermes | canonical | hermes | First-class; OpenClaw-compatible but distinct binary. | | grok | canonical | grok | Grok Build CLI; headless via -p; auth is env/file only (no status verb). | | deepseek | canonical | deepseek | DeepSeek dsh CLI; headless via --profile headless; auth is env/file only (no status verb). |

getAgentShim() and isAgentName() are alias-aware: passing 'antigravity' resolves to the antigravity-cli shim. DoctorResult and DetectedAgent surface deprecated + replacedBy when the resolved canonical id is marked deprecated.

Team Mode Primitives

The team-mode APIs are intentionally small building blocks. They do not implement an HTTP API, dashboard, or product workflow; downstream apps compose them into their own orchestration layer.

Agent specs

Agent specs define agents as config. The built-in parser supports the repository's constrained YAML subset: scalars, arrays, nested objects, and no anchors/tags/multiline scalars.

import { loadAgentSpecs, saveAgentSpec } from '@gobing-ai/ts-ai-runner';

await saveAgentSpec(
    {
        id: 'coder',
        name: 'Coder',
        type: 'codex',
        workspace: '/workspace/spur',
        purpose: 'Implement scoped code changes',
        tags: ['code'],
        config: { model: 'gpt-5', systemPrompt: 'Follow repository rules.' },
        autoStart: true,
    },
    './agents',
);

const specs = loadAgentSpecs('./agents');

validateAgentId() enforces lowercase agent ids with alphanumeric, _, and - characters (2–64 chars). deleteAgentSpec() removes a spec file by id.

Durable messages

TeamOrchestrator depends on the ai-runner-owned MessageStore port — a minimal interface with enqueue, drainPending, markDelivered, and markFailed — plus the DrainedMessage view containing only the fields the orchestrator consumes (id, fromId, body). The port is the orchestration boundary; production source under packages/ai-runner/src has no direct @gobing-ai/ts-db import for message access.

InboxMessageDao from @gobing-ai/ts-db/inbox is one structural provider: it satisfies MessageStore without an adapter class, so no ts-db runtime changes are required. In-memory test doubles implement the port directly. Consumers compose InboxMessageDao + EventBus<InboxMessageEvents> and pass the DAO to TeamOrchestrator:

import { type BusLifecycleEvents, EventBus } from '@gobing-ai/ts-infra';
import { InboxMessageDao, type InboxMessageEvents } from '@gobing-ai/ts-db/inbox';
import { formatMessage, type MessageStore } from '@gobing-ai/ts-ai-runner';

const lifecycleBus = new EventBus<BusLifecycleEvents>();
const events = new EventBus<InboxMessageEvents>({ lifecycleBus });
const inbox: MessageStore = new InboxMessageDao(adapter, { events });

const id = await inbox.enqueue(null, 'coder', 'Review the runtime process seam');
const pending = await inbox.drainPending('coder');

for (const msg of pending) {
    console.log(formatMessage(msg));
    await inbox.markDelivered(msg.id);
}

Message lifecycle events are metadata-only and do not include the durable message body. A message.failed event does include the caller-provided error string; pre-redact it when the lifecycle bus is attached to persistent System Events observers.

Persistent agent processes

TeamAgentProcess wraps a long-running agent subprocess using the runtime pipe-process seam. It supports start/stop, stdin sends, stdout/stderr subscriptions, status, pid, and exit-code queries.

import { TeamAgentProcess, type AgentSpec } from '@gobing-ai/ts-ai-runner';

const spec: AgentSpec = {
    id: 'coder',
    name: 'Coder',
    type: 'codex',
    workspace: '/workspace/spur',
    purpose: 'Implement scoped code changes',
    tags: [],
    config: {},
};

const process = new TeamAgentProcess({
    spec,
    command: ['codex', 'exec', 'You are coder. Wait for inbox messages.'],
});

const unsubscribe = process.subscribe((chunk) => {
    console.log(chunk.toString());
});

await process.start();
await process.send('[task from=operator id=msg-1] Inspect packages/db');
await process.stop();
unsubscribe();

Team orchestrator

TeamOrchestrator connects specs, shims, processes, and the MessageStore port. On start it loads an agent spec, builds the agent command through the matching shim, starts the process, drains pending inbox messages, and injects them live. sendMessage() always persists first, then injects immediately when the target agent is running.

import { type BusLifecycleEvents, EventBus } from '@gobing-ai/ts-infra';
import { InboxMessageDao, type InboxMessageEvents } from '@gobing-ai/ts-db/inbox';
import { TeamOrchestrator, type MessageStore } from '@gobing-ai/ts-ai-runner';

const lifecycleBus = new EventBus<BusLifecycleEvents>();
const events = new EventBus<InboxMessageEvents>({ lifecycleBus });
const inbox: MessageStore = new InboxMessageDao(adapter, { events });
const team = new TeamOrchestrator('./agents', inbox, { lifecycleBus });

await team.startAgent('coder');
await team.sendMessage(null, 'coder', 'Please implement task 0005');

console.log(await team.getAgentStatus('coder')); // running

await team.stopAll();

Any object implementing MessageStore can be supplied to TeamOrchestrator; InboxMessageDao is one structural provider, and in-memory test doubles implement the port directly without an adapter class. The orchestrator also provides restartAgent(id), getRunningAgents(), getPeerSpecs(workspace, excludeId?), and on(event, listener) for event subscription.

Adding a New Coding Agent

To add support for a new coding agent (e.g. amp), follow these steps:

1. Add the shim

Edit src/agents/shims.ts. Add a new constant implementing the AgentShim interface:

const ampShim: AgentShim = {
    name: 'amp',
    command: 'amp',
    tier: 1,  // or 2 if gateway/TUI-constrained
    getHelpCommand: () => ({ command: 'amp', args: ['--help'] }),
    getVersionCommand: () => ({ command: 'amp', args: ['--version'] }),
    getPromptCommand: (options) => {
        const args = ['run', options.input ?? ''];
        if (options.continue === true) args.push('--resume');
        if (options.model !== undefined) args.push('--model', options.model);
        if ((options.mode ?? 'text') === 'json') args.push('--json');
        return { command: 'amp', args };
    },
    getAuthCommand: () => ({ command: 'amp', args: ['auth', 'status'] }),
};

2. Register the shim

In the same file, add the new agent to these three places:

// 1. The AgentName union type (canonical ids only — aliases are metadata, not union members)
export type AgentName = 'claude' | 'codex' | 'gemini' | 'pi' | 'omp' | 'opencode' | 'antigravity-cli' | 'openclaw' | 'hermes' | 'amp';

// 2. The AGENT_SHIMS registry
export const AGENT_SHIMS: Readonly<Record<AgentName, AgentShim>> = {
    // ...existing entries...
    amp: ampShim,
};

// 3. DISPLAY_ORDER (determines doctor/detector output order)
export const DISPLAY_ORDER: readonly AgentName[] = [
    'claude', 'codex', 'gemini', 'pi', 'omp', 'opencode', 'antigravity-cli', 'openclaw', 'hermes', 'amp',
];

If the agent is tier 1, add it to TIER1_PRIORITY. If tier 2 (gateway/TUI-constrained), add it to TIER2_AGENTS.

3. Add slash-command dialect (if needed)

If the agent uses a different slash-command syntax than the default /{plugin}-{command} args mapping, add a case to translateSlashCommand() in src/slash-command.ts.

4. Add auth detection (if applicable)

If the agent supports an auth-status command, getAuthCommand() already returns it and DoctorRunner will probe it automatically. For env-only or file-based auth, add a pattern to AUTH_PATTERNS in src/doctor-runner.ts.

5. Add tests

  • Shim tests: add cases to tests/agents/ verifying command construction for help, version, prompt (with and without options), and auth.
  • Detector tests: verify detectOne('amp') handles version output and error cases.
  • Doctor tests: verify auth probe for the new agent.

Summary checklist

| Step | File | What to change | | ------ | ------ | ---------------- | | Shim | src/agents/shims.ts | Add AgentShim impl, update AgentName, AGENT_SHIMS, DISPLAY_ORDER | | Tier | src/agents/shims.ts | Add to TIER1_PRIORITY or TIER2_AGENTS | | Slash | src/slash-command.ts | Add case if dialect differs from default | | Auth | src/doctor-runner.ts | Add AUTH_PATTERNS entry if file/env-based | | Tests | tests/ | Shim, detector, and doctor coverage |

After these changes, the new agent is automatically available to AiRunner, AgentDetector, DoctorRunner, TeamOrchestrator, and all downstream consumers — no further registration needed.

Decision Making

createDecisionMaker() exposes a provider-neutral LLM decision surface: ask N questions against one shared state and get typed, discriminated answers back. Questions are built with the q builders; answers are decoded into neutral ChoiceAnswer / ScoreAnswer / NoulAnswer types — no SDK type crosses this boundary.

Named backends are selected with backend:: typesafe (the default, reads TYPESAFE_API_KEY), laya-local (@gobing-ai/ts-laya-mlx), and fm-local (@gobing-ai/ts-decision-fm). Both local backends resolve their driver by dynamic import on first use — this package declares no dependency on either driver package (ADR-028); install the one you select. For typed, non-default driver options, construct the driver yourself and pass it as driver:.

Batch ask — many questions, one request

All questions in one ask call share the state and cost a single backend request:

import { createDecisionMaker, q } from '@gobing-ai/ts-ai-runner';

const decisions = createDecisionMaker(); // reads TYPESAFE_API_KEY from the environment
const state = { ticket: 'T-1042', body: 'Users cannot reset their passwords.' };

const answers = await decisions.ask({
    state,
    questions: {
        route: q.choice('Which team should own this?', {
            billing: 'Invoices and payment issues',
            access: 'Login and account access',
            other: 'Anything else',
        }),
        urgency: q.score('How urgent is this?', ['Routine', 'Elevated', 'Drop everything']),
        duplicate: q.noul('Has this ticket been reported before?'),
    },
});

answers.route.label; // 'billing' | 'access' | 'other'
answers.urgency.score; // rubric level
answers.duplicate.probability; // yes-probability

Single-question sugar

choice, score, and noul are one-question convenience forms over ask:

const route = await decisions.choice(state, 'Which team should own this?', {
    billing: 'Invoices and payment issues',
    access: 'Login and account access',
});
route.label; // 'billing' | 'access'

const duplicate = await decisions.noul(state, 'Has this ticket been reported before?');
duplicate.probability; // yes-probability — no `confidence` field: the API reports none for yes/no

A yes/no answer carries only probability. The API returns no calibration confidence for noul questions, so none is synthesized.

Configuration

The API key resolves as options.apiKey, else TYPESAFE_API_KEY from the injected env record, else from the process environment. A missing key throws DecisionConfigError before any request:

const key = 'sk-…'; // from your secret store
const explicit = createDecisionMaker({ apiKey: key }); // explicit key wins
const sandboxed = createDecisionMaker({ env: { TYPESAFE_API_KEY: key } }); // injected record — the host owns the environment

Other options: model, baseURL, timeoutMs, maxRetries, and an injected fetch for tests. When baseURL is omitted the SDK resolves TYPESAFE_BASE_URL from the environment itself — pass baseURL explicitly to pin the endpoint.

Errors

SDK failures and malformed questions/responses surface as DecisionError subclasses. Custom drivers' own exceptions propagate; no vendor SDK error class escapes the default driver:

| Error | When | Carries | | ----- | ------- | ------- | | DecisionConfigError | No API key resolved | variable name | | DecisionAuthError | Authentication or permission rejected upstream | HTTP status | | DecisionRateLimitError | Rate limited upstream | status, retryAfterMs | | DecisionTimeoutError | Request timed out | timeoutMs | | DecisionConnectionError | Backend unreachable | underlying cause | | DecisionRequestError | Invalid question or request rejected as malformed (other 4xx) | status, body summary | | DecisionBackendError | Invalid answer or upstream server failure (5xx) | HTTP status when present |

The facade validates both default and custom drivers: answer names/kinds must match the questions, choice labels must belong to the supplied label map, probability/confidence values must be finite and within [0, 1], and score values must fit the zero-indexed rubric (fractional expected scores are supported). Missing probability/legend keys are rejected. These checks establish structural validity, not calibration or decision quality.

Applications own decision policy and evidence selection. For example, Spur can adapt its existing HitlResponder to use DecisionMaker while retaining its own HITL actions and fallback behavior; the workflow engine itself has no dependency on ai-runner.

Adding a backend driver

Additional backend drivers are the intended extension point. A driver implements only ask — question building, the single-question sugar, and answer typing all come from the facade:

import type { Answer, DecisionDriver } from '@gobing-ai/ts-ai-runner';

const myDriver: DecisionDriver = {
    name: 'my-backend',
    async ask() {
        const answers: Record<string, Answer> = {};
        // Call your backend; return one answer per key in the question map.
        return answers;
    },
};

const custom = createDecisionMaker({ driver: myDriver }); // no TYPESAFE_API_KEY required

Boundary Notes

  • This package is a command adapter, not an agent orchestration framework.
  • It does not install agent CLIs or manage credentials.
  • It does not parse agent responses beyond process result capture.
  • It keeps subprocess launching behind ProcessExecutor / PipeProcess, so tests can stay deterministic.
  • Team-mode persistence is consumed through the ai-runner-owned MessageStore port; InboxMessageDao from @gobing-ai/ts-db/inbox is one structural provider. Host apps own migrations and adapter lifecycle, and @gobing-ai/ts-db is a development dependency of this package (only the DB-backed integration test resolves the concrete DAO).
  • Platform APIs (node:fs, node:path, Bun.spawn, etc.) are confined to @gobing-ai/ts-runtime per ADR-011. This package accesses them through the runtime's FileSystem, ProcessExecutor, and path utilities.