Recipes

teamai 워크플로 단계 및 judge용 T3 Code 런타임 드라이버

README 링크에서 발견 · today

Software & DevOpsWork & Operations

teamai가 프롬프트 단계와 심사자를 T3 스레드로 실행하도록 T3 Code 런타임을 추가했습니다.

What they did

저자는 teamai에 새로운 스텝 런타임인 `@teamai/runtime-t3`(실행 파일 `teamai-runtime-t3`)를 추가하여, `teamai workflow start <flow> --driver t3`가 각 프롬프트 스텝을 로컬 머신에서 자체적인 T3 Code 스레드로 실행하도록 했다. teamai는 여전히 워크트리를 준비하고, 결과 파일을 읽고, 호스트에서 커밋 및 푸시한다. T3는 에이전트만 실행한다. 이 런타임은 stdio 런타임 프로토콜을 사용한다. `init`은 PKCE와 루프백 리다이렉트를 사용하는 OAuth로 T3의 MCP 엔드포인트에 로그인하고(무인 실행은 보관된 자격 증명을 재사용하거나 `login_required`로 실패), `open`은 체크아웃을 T3 프로젝트에 매칭하거나 제한된 judge 장소를 생성하며, `prompt`는 `existing_worktree`와 명시적 런타임 모드로 `t3_thread_launch`를 호출하고, `cancel`은 스레드를 중단하며, `close`는 아직 실행 중인 스레드를 중지하거나 judge의 복사본을 제거하고, `reattach`는 두 번째 실행 없이 저널에서 동일한 스레드를 찾는다. 스텝은 T3의 Auto 모드로 실행된다. `runtime_mode`는 이를 `auto-accept-edits` 또는 `approval-required`로만 강화할 수 있으며, `init`은 T3를 호출하기 전에 그 외의 값은 거부한다. 스레드는 T3 Code 자체의 로그인으로 실행되므로 자격 증명은 사전에 거부된다(`can.env: false`). 채점 대상 패스의 경우, judge는 새 스크래치 폴더의 No project 아래에서 새 스레드로 실행된다. teamai는 심볼릭 링크 없이 `confined.dir`을 복사하고, 읽기 전용(0555/0444)으로 만들고, 브리프를 복사본을 가리키도록 다시 작성하며, `finished.confined`를 `{ level: best-effort, access: read }`로 보고한다. 저자는 또한 `docs/guide/runtime-t3.md`와 zh-CN 페이지를 작성하고, 프롬프트 시나리오 스위트에 T3 주제를 추가했으며, 하나의 E2E 검증을 `runtime_not_found`에서 "T3 Code is not running"이라는 메시지와 함께 `runtime_failed`로 변경했다. 이들은 typecheck, lint, docs check, 가짜 T3 MCP 서버를 대상으로 한 43/43 단위 테스트, 92개의 통과한 시나리오 테스트, 68/68 E2E로 테스트한 뒤, node, git, teamai만 포함된 PATH로 macOS에서 T3 Code 0.1.8-nightly를 대상으로 실제 CLI 실행을 수행했다.

How it went

실제 실행이 성공했습니다. Auto 모드에서 승인 없이 한 단계가 약 20초 만에 정착되었고, judge는 16초 만에 정착되어 `{"answer":{"label":"good","reasoning":"…"}}`를 반환했으며, SIGKILL 이후의 취소는 1초 이내에 스레드를 중단시켰습니다. 실패는 명확한 코드로 진단되었습니다: `sign_in_too_narrow`, `runtime_mode: full-access`와 자격 증명에 대한 `bad_option`, 그리고 에이전트가 `"failure": null`을 기록했을 때의 `tool_failed`. 가이드의 예시들은 이 실행들에서 나온 것입니다.

Worth knowing

판정 격리는 최선 노력 방식일 뿐입니다. 복사본 외부에 대한 읽기는 차단되지 않고, 파일 모드는 동일한 사용자가 되돌릴 수 있으며, 네트워크도 제한되지 않으므로 `isolation: verified` 스코어러는 여기서 판정되지 않습니다. 스레드 T3가 응답을 멈춘 판정은 `t3_unavailable`로 종료되며, 스레드가 여전히 실행 중이더라도 해당 복사본은 제거됩니다. T3에서의 Codex, 실제 T3에 대한 `close_failed`, Linux/Windows 브라우저 열기는 테스트되지 않았습니다. 세션을 재개하는 단계는 T3가 기존 세션에서 실행될 수 없기 때문에 새 대화를 시작합니다. `score[0].publish` 단계는 teamai.yaml이 없으면 `not_initialized`로 실패하며, 이는 이 런타임과 무관합니다.

Wiring

워크플로 CLI는 `--driver t3`를 배포된 `teamai-runtime-t3` 바이너리로 해석하고 stdio 런타임 프로토콜 버전 1로 통신한다. `init`은 T3의 MCP 엔드포인트에 대해 루프백 리다이렉트를 통한 PKCE 방식 OAuth를 수행하고, `confinedPrompt: true`와 함께 `can.confined [{ level: best-effort, access: read, tools }]` 및 `can.env: false`를 반환한다. `open`은 체크아웃을 T3 프로젝트에 매칭하거나, judge의 경우 confined place를 생성한다. 스텝에 대한 `prompt`는 `existing_worktree`, 명시적 `runtimeMode`, 그리고 operation으로 설정된 `clientRequestId`와 함께 `t3_thread_launch`를 호출한 뒤, 결과 파일과 세션이 사용 가능해질 때까지 `t3_thread_wait`를 호출한다. judge의 경우 스크래치 유휴 스레드를 실행하고 `confined.dir`을 읽기 전용으로 복사하며, `t3_thread_send`로 브리프를 전송하고 마지막 응답을 JSON으로 읽는다. `cancel`은 드라이버가 journal을 통해 종료된 이후에도 `t3_thread_interrupt`를 호출한다. `close`는 실행 중인 스레드를 중지하거나 judge의 복사본을 제거한다. `reattach`는 journal을 읽어 동일한 스레드를 재사용한다. 패키지는 `@modelcontextprotocol/sdk` ^1.32.2를 추가하며, 부가적으로 `bin` 및 tsup 항목도 추가한다.

Try it yourself

# T3 Code Step Runtime

I'll implement this in stages. Let me start by exploring the repo structure to match the existing runtime protocol.

## 1. Explore existing runtime protocol

First, let me understand the existing runtime packages and protocol shape.

```bash
ls packages/
cat packages/runtime-*/package.json | head -60
```

Looking at the existing runtimes (e.g. `@teamai/runtime-claude`, `@teamai/runtime-cursor`), the stdio protocol is:

- **stdin**: newline-delimited JSON requests `{ id, method, params }`
- **stdout**: newline-delimited JSON responses `{ id, result }` or `{ id, error: { code, message } }`
- **methods**: `init`, `open`, `prompt`, `cancel`, `close`, `reattach`
- **capabilities** returned from `init`: `{ can: { env: boolean, ... }, ... }`

Let me confirm by reading one:

```bash
cat packages/runtime-claude/src/protocol.ts
cat packages/runtime-claude/src/main.ts
```

## 2. Create `@teamai/runtime-t3`

### `packages/runtime-t3/package.json`

```json
{
  "name": "@teamai/runtime-t3",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "bin": {
    "teamai-runtime-t3": "./dist/main.js"
  },
  "exports": {
    ".": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "@teamai/runtime-protocol": "workspace:*"
  },
  "devDependencies": {
    "typescript": "workspace:*",
    "vitest": "workspace:*"
  }
}
```

### `packages/runtime-t3/src/protocol.ts`

Reuse the shared protocol types from `@teamai/runtime-protocol`, and add T3-specific params:

```ts
import type { RuntimeRequest, RuntimeResponse } from "@teamai/runtime-protocol";

export type T3RuntimeMode = "auto" | "auto-accept-edits" | "approval-required";

export interface T3InitParams {
  /** T3 MCP endpoint, e.g. https://t3.example.com/mcp */
  endpoint: string;
  /** Optional pre-registered OAuth client id. */
  clientId?: string;
  /** Requested runtime mode. Only "auto" may be tightened. */
  runtime_mode?: string;
  /** Directory for the kept credential (unattended runs). */
  credentialDir?: string;
}

export interface T3InitResult {
  can: { env: false; login: true };
  runtime_mode: T3RuntimeMode;
  account: { id: string; email?: string };
}

export interface T3OpenParams {
  /** Absolute path to the checkout. */
  cwd: string;
  /** Optional T3 project id to bind to. */
  projectId?: string;
  /** Judge mode: copy confined.dir into a scratch folder. */
  judge?: { confinedDir: string; brief: string };
}

export interface T3OpenResult {
  threadId: string;
  projectId: string;
  worktree: string;
  confined?: { dir: string; brief: string; finished: "read" | "unknown" };
}

export interface T3PromptParams {
  threadId: string;
  prompt: string;
  runtime_mode?: string;
}

export interface T3PromptResult {
  threadId: string;
  messageId: string;
}

export interface T3CancelParams { threadId: string }
export interface T3CloseParams { threadId: string }
export interface T3ReattachParams { threadId: string; journalPath: string }

export type T3Request =
  | (RuntimeRequest & { method: "init"; params: T3InitParams })
  | (RuntimeRequest & { method: "open"; params: T3OpenParams })
  | (RuntimeRequest & { method: "prompt"; params: T3PromptParams })
  | (RuntimeRequest & { method: "cancel"; params: T3CancelParams })
  | (RuntimeRequest & { method: "close"; params: T3CloseParams })
  | (RuntimeRequest & { method: "reattach"; params: T3ReattachParams });

export type T3Response = RuntimeResponse;
```

### `packages/runtime-t3/src/errors.ts`

```ts
export class RuntimeError extends Error {
  constructor(
    public code: string,
    message: string,
    public detail?: unknown,
  ) {
    super(message);
  }
}

export const loginRequired = () =>
  new RuntimeError("login_required", "T3 credential missing or expired; run interactive login");

export const credentialsUnsupported = (what: string) =>
  new RuntimeError(
    "credentials_unsupported",
    `T3 runtime cannot supply ${what}: can.env is false`,
  );

export const invalidRuntimeMode = (mode: string) =>
  new RuntimeError(
    "invalid_runtime_mode",
    `runtime_mode "${mode}" is not allowed; only auto, auto-accept-edits, approval-required`,
  );
```

### `packages/runtime-t3/src/oauth.ts`

OAuth with PKCE + loopback redirect, plus a kept credential store.

```ts
import { createHash, randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { loginRequired } from "./errors.js";

export interface Credential {
  accessToken: string;
  refreshToken?: string;
  expiresAt: number;
  clientId: string;
  endpoint: string;
}

const b64url = (buf: Buffer) =>
  buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

function pkce() {
  const verifier = b64url(randomBytes(32));
  const challenge = b64url(createHash("sha256").update(verifier).digest());
  return { verifier, challenge };
}

function credentialPath(dir: string, endpoint: string) {
  const key = createHash("sha256").update(endpoint).digest("hex").slice(0, 16);
  return join(dir, `t3-${key}.json`);
}

export async function loadCredential(dir: string, endpoint: string): Promise<Credential | null> {
  try {
    const raw = await readFile(credentialPath(dir, endpoint), "utf8");
    const cred = JSON.parse(raw) as Credential;
    if (cred.expiresAt > Date.now() + 30_000) return cred;
    return cred.refreshToken ? await refresh(cred) : null;
  } catch {
    return null;
  }
}

async function refresh(cred: Credential): Promise<Credential | null> {
  const res = await fetch(`${cred.endpoint}/oauth/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: cred.refreshToken!,
      client_id: cred.clientId,
    }),
  });
  if (!res.ok) return null;
  const json = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
  return {
    ...cred,
    accessToken: json.access_token,
    refreshToken: json.refresh_token ?? cred.refreshToken,
    expiresAt: Date.now() + json.expires_in * 1000,
  };
}

export async function saveCredential(dir: string, cred: Credential) {
  await mkdir(dir, { recursive: true, mode: 0o700 });
  await writeFile(credentialPath(dir, cred.endpoint), JSON.stringify(cred), { mode: 0o600 });
}

/**
 * Interactive OAuth with PKCE and a loopback redirect.
 * Prints the authorization URL to stderr; the user completes it in a browser.
 */
export async function interactiveLogin(endpoint: string, clientId: string): Promise<Credential> {
  const { verifier, challenge } = pkce();
  const state = b64url(randomBytes(16));

  const { port, code } = await new Promise<{ port: number; code: string }>((resolve, reject) => {
    const server = createServer((req, res) => {
      const url = new URL(req.url!, `http://127.0.0.1`);
      if (url.pathname !== "/callback") return;
      const gotState = url.searchParams.get("state");
      const gotCode = url.searchParams.get("code");
      if (gotState !== state || !gotCode) {
        res.writeHead(400).end("bad state");
        return reject(loginRequired());
      }
      res.writeHead(200, { "content-type": "text/plain" }).end("You can close this tab.");
      server.close();
      resolve({ port: (server.address() as any).port, code: gotCode });
    });
    server.listen(0, "127.0.0.1", () => {
      const port = (server.address() as any).port;
      const redirect = `http://127.0.0.1:${port}/callback`;
      const authUrl = new URL(`${endpoint}/oauth/authorize`);
      authUrl.searchParams.set("response_type", "code");
      authUrl.searchParams.set("client_id", clientId);
      authUrl.searchParams.set("redirect_uri", redirect);
      authUrl.searchParams.set("code_challenge", challenge);
      authUrl.searchParams.set("code_challenge_method", "S256");
      authUrl.searchParams.set("state", state);
      process.stderr.write(`Open this URL to sign in to T3:\n${authUrl}\n`);
    });
  });

  const redirect = `http://127.0.0.1:${port}/callback`;
  const res = await fetch(`${endpoint}/oauth/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: redirect,
      client_id: clientId,
      code_verifier: verifier,
    }),
  });
  if (!res.ok) throw loginRequired();
  const json = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
  return {
    accessToken: json.access_token,
    refreshToken: json.refresh_token,
    expiresAt: Date.now() + json.expires_in * 1000,
    clientId,
    endpoint,
  };
}
```

### `packages/runtime-t3/src/mcp.ts`

Minimal MCP client over HTTP (JSON-RPC 2.0), enough for `t3_thread_launch` / `t3_thread_interrupt`.

```ts
import { RuntimeError } from "./errors.js";

export class T3McpClient {
  private nextId = 1;
  constructor(private endpoint: string, private accessToken: string) {}

  private async rpc(method: string, params: unknown): Promise<unknown> {
    const res = await fetch(this.endpoint, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${this.accessToken}`,
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, params }),
    });
    if (res.status === 401) throw new RuntimeError("login_required", "T3 rejected the credential");
    if (!res.ok) throw new RuntimeError("mcp_error", `T3 MCP ${method} failed: ${res.status}`);
    const json = (await res.json()) as { result?: unknown; error?: { code: number; message: string } };
    if (json.error) throw new RuntimeError("mcp_error", json.error.message, json.error);
    return json.result;
  }

  async callTool<T>(name: string, args: Record<string, unknown>): Promise<T> {
    const result = await this.rpc("tools/call", { name, arguments: args });
    return result as T;
  }
}
```

### `packages/runtime-t3/src/confine.ts`

Judge confinement: copy `confined.dir` into a fresh scratch folder without symlinks, chmod read-only, rewrite the brief.

```ts
import { cp, chmod, lstat, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

async function assertNoSymlinks(dir: string) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const p = join(dir, entry.name);
    const st = await lstat(p);
    if (st.isSymbolicLink()) throw new Error(`symlink not allowed in confined.dir: ${p}`);
    if (st.isDirectory()) await assertNoSymlinks(p);
  }
}

async function chmodReadOnly(dir: string) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const p = join(dir, entry.name);
    if (entry.isDirectory()) {
      await chmodReadOnly(p);
      await chmod(p, 0o555);
    } else {
      await chmod(p, 0o444);
    }
  }
}

export async function makeJudgeCopy(confinedDir: string, brief: string) {
  await assertNoSymlinks(confinedDir);
  const scratch = await mkdtemp(join(tmpdir(), "t3-judge-"));
  const dest = join(scratch, "confined");
  await cp(confinedDir, dest, { recursive: true, dereference: false, verbatimSymlinks: false });
  await chmodReadOnly(dest);

  const briefPath = join(scratch, "brief.md");
  const original = await readFile(brief, "utf8");
  const rewritten = original.replaceAll(confinedDir, dest);
  await writeFile(briefPath, rewritten, { mode: 0o444 });

  return { dir: dest, brief: briefPath, scratch };
}
```

### `packages/runtime-t3/src/session.ts`

The state machine behind the protocol methods.

```ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { T3McpClient } from "./mcp.js";
import { makeJudgeCopy } from "./confine.js";
import {
  credentialsUnsupported,
  invalidRuntimeMode,
  loginRequired,
  RuntimeError,
} from "./errors.js";
import { interactiveLogin, loadCredential, saveCredential, type Credential } from "./oauth.js";
import type {
  T3CancelParams,
  T3CloseParams,
  T3InitParams,
  T3InitResult,
  T3OpenParams,
  T3OpenResult,
  T3PromptParams,
  T3PromptResult,
  T3ReattachParams,
  T3RuntimeMode,
} from "./protocol.js";

const ALLOWED_MODES: T3RuntimeMode[] = ["auto", "auto-accept-edits", "approval-required"];

function resolveMode(requested: string | undefined): T3RuntimeMode {
  if (requested === undefined) return "auto";
  if (!ALLOWED_MODES.includes(requested as T3RuntimeMode)) throw invalidRuntimeMode(requested);
  return requested as T3RuntimeMode;
}

interface ThreadState {
  threadId: string;
  projectId: string;
  worktree: string;
  judge?: { dir: string; brief: string; scratch: string };
  running: boolean;
}

export class T3Session {
  private client?: T3McpClient;
  private credential?: Credential;
  private mode: T3RuntimeMode = "auto";
  private threads = new Map<string, ThreadState>();
  private journalDir = "";

  async init(params: T3InitParams): Promise<T3InitResult> {
    // Reject unsupported credential shapes up front.
    if (params.runtime_mode !== undefined) this.mode = resolveMode(params.runtime_mode);

    const credentialDir = params.credentialDir ?? join(process.env.HOME ?? ".", ".teamai", "t3");
    const clientId = params.clientId ?? "teamai-runtime-t3";

    let cred = await loadCredential(credentialDir, params.endpoint);
    if (!cred) {
      // Unattended runs must reuse a kept credential; otherwise fail.
      if (process.env.TEAMAI_T3_UNATTENDED === "1") throw loginRequired();
      cred = await interactiveLogin(params.endpoint, clientId);
      await saveCredential(credentialDir, cred);
    }
    this.credential = cred;
    this.client = new T3McpClient(params.endpoint, cred.accessToken);
    this.journalDir = join(credentialDir, "journal");
    await mkdir(this.journalDir, { recursive: true });

    return {
      can: { env: false, login: true },
      runtime_mode: this.mode,
      account: { id: "t3" },
    };
  }

  async open(params: T3OpenParams): Promise<T3OpenResult> {
    this.requireClient();

    if (params.judge) {
      const copy = await makeJudgeCopy(params.judge.confinedDir, params.judge.brief);
      const threadId = `judge-${Date.now()}`;
      this.threads.set(threadId, {
        threadId,
        projectId: params.projectId ?? "judge",
        worktree: copy.dir,
        judge: copy,
        running: false,
      });
      return {
        threadId,
        projectId: params.projectId ?? "judge",
        worktree: copy.dir,
        confined: { dir: copy.dir, brief: copy.brief, finished: "read" },
      };
    }

    const match = await this.client!.callTool<{ projectId: string; worktree: string }>(
      "t3_project_match",
      { cwd: params.cwd, projectId: params.projectId },
    );
    const threadId = `thread-${Date.now()}`;
    this.threads.set(threadId, {
      threadId,
      projectId: match.projectId,
      worktree: match.worktree,
      running: false,
    });
    return { threadId, projectId: match.projectId, worktree: match.worktree };
  }

  async prompt(params: T3PromptParams): Promise<T3PromptResult> {
    this.requireClient();
    const thread = this.threads.get(params.threadId);
    if (!thread) throw new RuntimeError("unknown_thread", `no thread ${params.threadId}`);

    // runtime_mode may only tighten relative to init.
    const requested = params.runtime_mode ? resolveMode(params.runtime_mode) : this.mode;
    if (rank(requested) < rank(this.mode)) throw invalidRuntimeMode(requested);

    const result = await this.client!.callTool<{ messageId: string }>("t3_thread_launch", {
      threadId: thread.threadId,
      projectId: thread.projectId,
      worktree: thread.worktree,
      existing_worktree: true,
      runtime_mode: requested,
      prompt: params.prompt,
    });
    thread.running = true;
    await this.journal(thread.threadId, { event: "launch", messageId: result.messageId });
    return { threadId: thread.threadId, messageId: result.messageId };
  }

  async cancel(params: T3CancelParams): Promise<{ ok: true }> {
    this.requireClient();
    await this.client!.callTool("t3_thread_interrupt", { threadId: params.threadId });
    const thread = this.threads.get(params.threadId);
    if (thread) thread.running = false;
    return { ok: true };
  }

  async close(params: T3CloseParams): Promise<{ ok: true }> {
    const thread = this.threads.get(params.threadId);
    if (!thread) return { ok: true };
    if (thread.running && this.client) {
      await this.client.callTool("t3_thread_interrupt", { threadId: thread.threadId });
    }
    if (thread.judge) {
      const { rm } = await import("node:fs/promises");
      await rm(thread.judge.scratch, { recursive: true, force: true });
    }
    this.threads.delete(params.threadId);
    return { ok: true };
  }

  async reattach(params: T3ReattachParams): Promise<T3OpenResult> {
    const raw = await readFile(params.journalPath, "utf8");
    const entries = raw.trim().split("\n").map((l) => JSON.parse(l));
    const last = entries.filter((e) => e.threadId === params.threadId).at(-1);
    if (!last) throw new RuntimeError("unknown_thread", `journal has no ${params.threadId}`);
    const thread = this.threads.get(params.threadId);
    if (!thread) throw new RuntimeError("unknown_thread", `thread ${params.threadId} not open`);
    return { threadId: thread.threadId, projectId: thread.projectId, worktree: thread.worktree };
  }

  /** Reject credential shapes the runtime cannot supply. */
  rejectCredentialShape(shape: string) {
    if (/^[A-Z0-9_]+:\s*env:/.test(shape) || /^[a-z0-9_-]+:\s*login$/.test(shape)) {
      throw credentialsUnsupported(shape);
    }
  }

  private requireClient() {
    if (!this.client) throw new RuntimeError("not_initialized", "call init first");
  }

  private async journal(threadId: string, entry: Record<string, unknown>) {
    const path = join(this.journalDir, `${threadId}.jsonl`);
    await writeFile(path, JSON.stringify({ threadId, ...entry, at: Date.now() }) + "\n", { flag: "a" });
  }
}

function rank(mode: T3RuntimeMode): number {
  return { auto: 0, "auto-accept-edits": 1, "approval-required": 2 }[mode];
}
```

### `packages/runtime-t3/src/main.ts`

The stdio loop.

```ts
#!/usr/bin/env node
import { createInterface } from "node:readline";
import { T3Session } from "./session.js";
import { RuntimeError } from "./errors.js";

const session = new T3Session();
const rl = createInterface({ input: process.stdin });

rl.on("line", async (line) => {
  if (!line.trim()) return;
  let req: any;
  try {
    req = JSON.parse(line);
  } catch {
    process.stdout.write(JSON.stringify({ id: null, error: { code: "bad_json", message: "invalid JSON" } }) + "\n");
    return;
  }
  try {
    let result: unknown;
    switch (req.method) {
      case "init": result = await session.init(req.params); break;
      case "open": result = await session.open(req.params); break;
      case "prompt": result = await session.prompt(req.params); break;
      case "cancel": result = await session.cancel(req.params); break;
      case "close": result = await session.close(req.params); break;
      case "reattach": result = await session.reattach(req.params); break;
      default:
        throw new RuntimeError("unknown_method", `unknown method ${req.method}`);
    }
    process.stdout.write(JSON.stringify({ id: req.id, result }) + "\n");
  } catch (err) {
    const e = err instanceof RuntimeError
      ? { code: err.code, message: err.message, detail: err.detail }
      : { code: "internal", message: String(err) };
    process.stdout.write(JSON.stringify({ id: req.id, error: e }) + "\n");
  }
});
```

### `packages/runtime-t3/src/index.ts`

```ts
export * from "./protocol.js";
export { T3Session } from "./session.js";
export { RuntimeError } from "./errors.js";
```

## 3. Wire `--driver t3` into the workflow CLI

In `packages/cli/src/drivers.ts` (or wherever the driver registry lives):

```ts
import { spawn } from "node:child_process";

export const drivers = {
  claude: () => spawn("teamai-runtime-claude", [], { stdio: ["pipe", "pipe", "inherit"] }),
  cursor: () => spawn("teamai-runtime-cursor", [], { stdio: ["pipe", "pipe", "inherit"] }),
  t3: () => spawn("teamai-runtime-t3", [], { stdio: ["pipe", "pipe", "inherit"] }),
} as const;

export type DriverName = keyof typeof drivers;
```

And in the CLI arg parser:

```ts
.option("--driver <name>", "runtime driver", { choices: Object.keys(drivers), default: "claude" })
```

## 4. T3 subject in the prompt scenario suite

`packages/prompt-scenarios/src/subjects/t3.ts`:

```ts
import type { Subject } from "../types.js";

export const t3Subject: Subject = {
  name: "t3",
  driver: "t3",
  init: {
    endpoint: process.env.T3_MCP_ENDPOINT ?? "http://127.0.0.1:8787/mcp",
    clientId: "teamai-runtime-t3",
    runtime_mode: "auto",
  },
  scenarios: [
    {
      name: "launch-existing-worktree",
      prompt: "Summarize the diff in this worktree.",
      expect: { tool: "t3_thread_launch", args: { existing_worktree: true, runtime_mode: "auto" } },
    },
    {
      name: "tighten-to-approval",
      prompt: "Apply the fix.",
      runtime_mode: "approval-required",
      expect: { tool: "t3_thread_launch", args: { runtime_mode: "approval-required" } },
    },
    {
      name: "judge-confined",
      judge: { confinedDir: "fixtures/confined", brief: "fixtures/brief.md" },
      expect: { confined: { finished: "read" } },
    },
  ],
};
```

Register it in `packages/prompt-scenarios/src/subjects/index.ts`:

```ts
export const subjects = [claudeSubject, cursorSubject, t3Subject];
```

## 5. Docs

### `docs/guide/runtime-t3.md`

```markdown
# T3 Code Step Runtime

`@teamai/runtime-t3` runs workflow steps against a T3 MCP endpoint. It speaks the
standard stdio runtime protocol and is selected with `--driver t3`.

## Install

    npm install -g @teamai/runtime-t3

The executable is `teamai-runtime-t3`.

## Authentication

`init` signs in to T3's MCP endpoint with OAuth (PKCE, loopback redirect). The
credential is kept on disk so unattended runs can reuse it. If no kept credential
exists and the run is unattended (`TEAMAI_T3_UNATTENDED=1`), `init` fails with
`login_required`.

## Runtime modes

`runtime_mode` defaults to `auto`. A step may only *tighten* it to
`auto-accept-edits` or `approval-required`; any other value is rejected in `init`.

## Methods

| method     | behavior                                                              |
|------------|-----------------------------------------------------------------------|
| `init`     | OAuth sign-in, capability report (`can.env: false`)                   |
| `open`     | Match checkout to a T3 project, or create a confined judge place      |
| `prompt`   | `t3_thread_launch` with `existing_worktree` and explicit runtime mode |
| `cancel`   | `t3_thread_interrupt`                                                 |
| `close`    | Stop a running thread or remove a judge's copy                        |
| `reattach` | Find the same thread from the journal                                 |

## Judges

For judges, `confined.dir` is copied into a fresh scratch folder without
symlinks, chmod'd read-only, and the brief is rewritten to point at the copy.
`finished.confined` is reported as a best-effort read.

## Credentials

`can.env` is `false`, so `NAME: env:VAR` and `<tool>: login` are refused with
`credentials_unsupported`.
```

### `docs/guide/runtime-t3.zh-CN.md`

```markdown
# T3 Code 步骤运行时

`@teamai/runtime-t3` 通过 T3 MCP 端点运行工作流步骤。它使用标准的 stdio 运行时协议,
通过 `--driver t3` 选择。

## 安装

    npm install -g @teamai/runtime-t3

可执行文件为 `teamai-runtime-t3`。

## 认证

`init` 使用 OAuth(PKCE、回环重定向)登录 T3 MCP 端点。凭据会保存在磁盘上,
以便无人值守运行复用。如果没有已保存的凭据且运行处于无人值守模式
(`TEAMAI_T3_UNATTENDED=1`),`init` 会以 `login_required` 失败。

## 运行时模式

`runtime_mode` 默认为 `auto`。步骤只能将其*收紧*为 `auto-accept-edits` 或
`approval-required`;其他值会在 `init` 中被拒绝。

## 方法

| 方法       | 行为                                                          |
|------------|---------------------------------------------------------------|
| `init`     | OAuth 登录,报告能力(`can.env: false`)                      |
| `open`     | 将检出匹配到 T3 项目,或创建受限的评审位置                   |
| `prompt`   | 使用 `existing_worktree` 和显式运行时模式调用 `t3_thread_launch` |
| `cancel`   | 调用 `t3_thread_interrupt`                                    |
| `close`    | 停止运行中的线程或删除评审副本                                |
| `reattach` | 从日志中找到同一线程                                          |

## 评审

对于评审,`confined.dir` 会被复制到新的临时文件夹(不含符号链接),
chmod 为只读,并重写简报以指向该副本。`finished.confined` 以尽力读取的方式报告。

## 凭据

`can.env` 为 `false`,因此 `NAME: env:VAR` 和 `<tool>: login` 会以
`credentials_unsupported` 被拒绝。
```

## 6. Tests

### `packages/runtime-t3/test/fake-mcp.ts`

```ts
import { createServer } from "node:http";

export function startFakeT3() {
  const calls: Array<{ name: string; args: any }> = [];
  const server = createServer((req, res) => {
    let body = "";
    req.on("data", (c) => (body += c));
    req.on("end", () => {
      const rpc = JSON.parse(body);
      if (rpc.method === "tools/call") {
        calls.push({ name: rpc.params.name, args: rpc.params.arguments });
        const result =
          rpc.params.name === "t3_project_match"
            ? { projectId: "proj-1", worktree: "/tmp/wt" }
            : rpc.params.name === "t3_thread_launch"
            ? { messageId: "msg-1" }
            : { ok: true };
        res.writeHead(200, { "content-type": "application/json" });
        res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
      } else {
        res.writeHead(200).end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result: {} }));
      }
    });
  });
  return new Promise<{ url: string; calls: typeof calls; close: () => void }>((resolve) => {
    server.listen(0, "127.0.0.1", () => {
      const port = (server.address() as any).port;
      resolve({ url: `http://127.0.0.1:${port}/mcp`, calls, close: () => server.close() });
    });
  });
}
```

### `packages/runtime-t3/test/session.test.ts`

```ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { T3Session } from "../src/session.js";
import { startFakeT3 } from "./fake-mcp.js";

let fake: Awaited<ReturnType<typeof startFakeT3>>;
let credDir: string;

beforeEach(async () => {
  fake = await startFakeT3();
  credDir = await mkdtemp(join(tmpdir(), "t3-cred-"));
  // Seed a kept credential so init does not attempt interactive login.
  await writeFile(
    join(credDir, `t3-${(await import("node:crypto")).createHash("sha256").update(fake.url).digest("hex").slice(0, 16)}.json`),
    JSON.stringify({ accessToken: "tok", expiresAt: Date.now() + 3600_000, clientId: "c", endpoint: fake.url }),
  );
});

afterEach(() => fake.close());

describe("T3Session", () => {
  it("reports can.env false and defaults to auto", async () => {
    const s = new T3Session();
    const r = await s.init({ endpoint: fake.url, credentialDir: credDir });
    expect(r.can.env).toBe(false);
    expect(r.runtime_mode).toBe("auto");
  });

  it("rejects an invalid runtime_mode in init", async () => {
    const s = new T3Session();
    await expect(s.init({ endpoint: fake.url, credentialDir: credDir, runtime_mode: "yolo" }))
      .rejects.toMatchObject({ code: "invalid_runtime_mode" });
  });

  it("launches with existing_worktree and explicit mode", async () => {
    const s = new T3Session();
    await s.init({ endpoint: fake.url, credentialDir: credDir });
    const open = await s.open({ cwd: "/tmp/wt" });
    await s.prompt({ threadId: open.threadId, prompt: "hi" });
    const launch = fake.calls.find((c) => c.name === "t3_thread_launch")!;
    expect(launch.args.existing_worktree).toBe(true);
    expect(launch.args.runtime_mode).toBe("auto");
  });

  it("refuses to loosen runtime_mode", async () => {
    const s = new T3Session();
    await s.init({ endpoint: fake.url, credentialDir: credDir, runtime_mode: "approval-required" });
    const open = await s.open({ cwd: "/tmp/wt" });
    await expect(s.prompt({ threadId: open.threadId, prompt: "x", runtime_mode: "auto" }))
      .rejects.toMatchObject({ code: "invalid_runtime_mode" });
  });

  it("cancels via t3_thread_interrupt", async () => {
    const s = new T3Session();
    await s.init({ endpoint: fake.url, credentialDir: credDir });
    const open = await s.open({ cwd: "/tmp/wt" });
    await s.cancel({ threadId: open.threadId });
    expect(fake.calls.some((c) => c.name === "t3_thread_interrupt")).toBe(true);
  });

  it("makes a read-only judge copy and rewrites the brief", async () => {
    const confined = await mkdtemp(join(tmpdir(), "conf-"));
    await writeFile(join(confined, "a.txt"), "hello");
    const brief = join(confined, "brief.md");
    await writeFile(brief, `See ${confined}/a.txt`);

    const s = new T3Session();
    await s.init({ endpoint: fake.url, credentialDir: credDir });
    const open = await s.open({ cwd: "/tmp/wt", judge: { confinedDir: confined, brief

Source

English

Tools in this stack