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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ Remote: https://github.com/boringstack-xyz/tsforge
matches nothing fails silently, so `grep` the new text before writing it into
a commit message. Four commits in one session described fixes that were never
in the file.
- **While the pane UI is up, only the renderer writes to the terminal.** A raw
write paints over the frame; this leaked three times (jsdom warnings, library
`console.error`, MCP servers' stderr). Show UI text through `echo`/`streamOut`,
never `process.stdout.write`. Never let a child process inherit stdout/stderr:
Bun.spawn's default stderr is `"inherit"`, so always set it (`"pipe"` and
drain, or `"ignore"`). `TerminalGuard` diverts in-process strays at runtime;
`tests/terminal-ownership.test.ts` rejects inheriting spawns;
`scripts/e2e-terminal-ownership-pty.py` drives the real UI with noisy parts.
- **Scripts orchestrate; `src/` decides.** A file in `scripts/` is an entry
point — parse args, call into `src/`, print. Any function it would be a bug to
get wrong belongs in `src/`, where a test can import it. The graded score
Expand Down
36 changes: 23 additions & 13 deletions apps/docs/src/content/docs/integrations/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ Add an `mcpServers` block to `tsforge.config.json` at your repo root. An entry i
}
```

| Field | Required | Notes |
| --- | --- | --- |
| `command` | yes (stdio) | Executable to spawn |
| `args` | no | Arguments for `command` |
| `env` | no | Extra env vars (merged over the parent env); `${VAR}` interpolated |
| `type` | no | `stdio` (default) or `http` |
| `url` | yes (http) | The server's MCP endpoint, e.g. `https://crm.example.com/mcp` |
| `headers` | no (http) | Headers sent on every request, e.g. `{ "Authorization": "Bearer …" }`; `${VAR}` interpolated |
| `timeoutMs` | no | Per-call timeout (default 30000) |
| Field | Required | Notes |
| ----------- | ----------- | -------------------------------------------------------------------------------------------- |
| `command` | yes (stdio) | Executable to spawn |
| `args` | no | Arguments for `command` |
| `env` | no | Extra env vars (merged over the parent env); `${VAR}` interpolated |
| `type` | no | `stdio` (default) or `http` |
| `url` | yes (http) | The server's MCP endpoint, e.g. `https://crm.example.com/mcp` |
| `headers` | no (http) | Headers sent on every request, e.g. `{ "Authorization": "Bearer …" }`; `${VAR}` interpolated |
| `timeoutMs` | no | Per-call timeout (default 30000) |

### Global servers (`~/.tsforge/models.json`)

Expand All @@ -40,10 +40,16 @@ An `mcpServers` block also works in the global model registry, `~/.tsforge/model
```json title="~/.tsforge/models.json"
{
"active": "...",
"models": { "...": { } },
"models": { "...": {} },
"mcpServers": {
"linear": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] },
"sentry": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.sentry.dev/sse"] }
"linear": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"]
},
"sentry": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.sentry.dev/sse"]
}
}
}
```
Expand Down Expand Up @@ -85,7 +91,10 @@ Hosted servers that sign you in through a browser (OAuth), like Linear's, Notion
```json
{
"mcpServers": {
"linear": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] }
"linear": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"]
}
}
}
```
Expand All @@ -96,6 +105,7 @@ MCP fits tsforge's deterministic, local-first design as an **opt-in context sour

- MCP tools **never touch your editable scope** and **cannot satisfy or bypass the acceptance gate**. They add context, they don't certify "done".
- Because they don't write the workspace, they remain available in **plan mode** (e.g. look up docs while planning).
- A stdio server's own logging (its stderr, e.g. `mcp-remote`'s `[Local→Remote] tools/call` lines) **never reaches your terminal**. It goes to the debug trace (`TSFORGE_TRACE`), and if the server dies, its last lines appear in the error message.
- A server that fails to connect, crashes, or times out is **reported and skipped**. It can never block a session from starting or wedge the loop. A failed tool call comes back as an error string the model can react to.

## Limits today
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test": "bun test packages",
"check:bun": "bun packages/core/scripts/check-bun-version.ts",
"e2e": "python3 scripts/e2e-iterm-tui.py && python3 scripts/e2e-iterm-plan-mode.py",
"e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py && python3 scripts/e2e-inference-pathological-pty.py && python3 scripts/e2e-reviewfix-pty.py && python3 scripts/e2e-phaser-plan-pty.py && python3 scripts/e2e-harness-invariants-pty.py",
"e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py && python3 scripts/e2e-inference-pathological-pty.py && python3 scripts/e2e-reviewfix-pty.py && python3 scripts/e2e-phaser-plan-pty.py && python3 scripts/e2e-harness-invariants-pty.py && python3 scripts/e2e-terminal-ownership-pty.py",
"validate": "bun run check:bun && bun run typecheck && bun run lint && bun run format:check && bun run test && bun run e2e:pty",
"rules:build": "bun packages/core/scripts/build-rules-md.ts",
"rules:check": "bun run rules:build && git diff --exit-code packages/core/RULES.md",
Expand Down
8 changes: 4 additions & 4 deletions packages/core/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- Generated by `bun run arch:build` from packages/core/src. Do not edit by hand. -->

Derived from `packages/core/src`: **32 subsystems**, **746 files**, **146k lines**, **154 cross-subsystem edges**.
Derived from `packages/core/src`: **32 subsystems**, **748 files**, **146k lines**, **154 cross-subsystem edges**.


This page is the exhaustive record: every subsystem, every cross-subsystem edge, and
Expand All @@ -16,7 +16,7 @@ inventory, see the hand-drawn map on [Internals](/internals/).
| `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 177 | 49k | 8 | 24 |
| `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 191 | 23k | 3 | 3 |
| `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 45 | 11k | 7 | 7 |
| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 28 | 10k | 2 | 20 |
| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 29 | 10k | 2 | 20 |
| `agent` | Tool schemas, the model-as-agent wrapper, and the malformed-tool-call repair ladder | core | 11 | 5k | 9 | 9 |
| `site-plugins` | Built-in site experts (Reddit…) that read a site's structured data through the Chrome bridge | optional | 34 | 4k | 1 | 4 |
| `inference` | OpenAI-compatible provider: streaming, tool calls, reasoning, token usage | core | 12 | 3k | 10 | 4 |
Expand All @@ -32,7 +32,7 @@ inventory, see the hand-drawn map on [Internals](/internals/).
| `lib` | Shared primitives — fs, json, guards, scope globs, SSRF checks, clipboard | core | 21 | 2k | 26 | 0 |
| `files` | Reading, creating, and hash-anchored editing of workspace files | core | 9 | 2k | 5 | 1 |
| `policy` | Decides which actions are allowed in the current mode before they run | core | 5 | 2k | 5 | 3 |
| `mcp` | Model Context Protocol client that exposes external servers as tools | optional | 10 | 1k | 4 | 1 |
| `mcp` | Model Context Protocol client that exposes external servers as tools | optional | 11 | 2k | 4 | 1 |
| `chrome-bridge` | Localhost WebSocket bridge to the tsforge Chrome extension — read-only research in the user's browser | optional | 9 | 1k | 4 | 1 |
| `architecture` ⚠️ | Derives this map from source so the docs cannot drift from the code | optional | 8 | 1k | 0 | 0 |
| `lsp` | TypeScript language service powering navigation and write-time diagnostics | optional | 3 | <1k | 5 | 0 |
Expand Down Expand Up @@ -103,7 +103,7 @@ Async functions returning an exit code, declared under the CLI — the commands.
| `main` | `cli.ts:801` |
| `mapMode` | `cli.ts:520` |
| `recipesMode` | `cli.ts:539` |
| `repl` | `cli/repl.ts:1174` |
| `repl` | `cli/repl.ts:1175` |
| `reviewMode` | `cli.ts:203` |
| `runOnce` | `cli.ts:105` |
| `runTraceCommand` | `cli/repl-commands.ts:160` |
Expand Down
33 changes: 28 additions & 5 deletions packages/core/src/cli/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
import { setMcpDiagnosticSink } from "../mcp";
import { trace } from "../lib/trace";
import { divertStrayConsole } from "./stray-console";
import { TerminalGuard } from "./terminal-guard";
import { saveUserSetting } from "../config/user-settings";
import {
setBootHeadline,
Expand Down Expand Up @@ -2444,8 +2445,30 @@ export async function repl(args: ICliArgs): Promise<number> {
};
};

// Pane console — the only interactive UI on a TTY.
const paneScreen = new PaneScreen(process.stdout);
// Pane console — the only interactive UI on a TTY. While it owns the screen,
// the TerminalGuard lets only the renderer (and the few deliberate raw
// writers below, via `terminalGuard.terminal`) reach the terminal: any other
// stdout write becomes transcript text, stderr and library console noise go
// to the debug trace.
const terminalGuard = new TerminalGuard({
stdout: process.stdout,
stderr: process.stderr,
onStdout: (text) => {
paneScreen.appendMain(text);
},
onStderr: (text) => {
trace("stderr", text.trimEnd());
},
});
const paneScreen = new PaneScreen(terminalGuard.terminal);

paneScreen.setActivityListener((active) => {
if (active) {
terminalGuard.engage();
} else {
terminalGuard.release();
}
});
const tuiKeybindings = resolveTuiKeybindings(
parseProjectTuiKeybindings(delegationConfig),
loadUserTuiKeybindings()
Expand Down Expand Up @@ -2727,7 +2750,7 @@ export async function repl(args: ICliArgs): Promise<number> {

/** Raw terminal modes (bracketed paste, kitty keys) — never transcript. */
const writeTerm = (text: string): void => {
process.stdout.write(text);
terminalGuard.terminal.write(text);
};

// --- live agent tree ------------------------------------------------------
Expand Down Expand Up @@ -3450,7 +3473,7 @@ export async function repl(args: ICliArgs): Promise<number> {
cwd: args.dir,
suspend,
resume,
out: (s) => process.stdout.write(s),
out: (s) => terminalGuard.terminal.write(s),
columns: transcriptCols(),
viewportRows: overlayBudget(),
}).then(async (dest) => {
Expand All @@ -3468,7 +3491,7 @@ export async function repl(args: ICliArgs): Promise<number> {
},
columns: transcriptCols(),
viewportRows: overlayBudget(),
out: (s) => process.stdout.write(s),
out: (s) => terminalGuard.terminal.write(s),
runRecipe: (recipe) => {
if (recipe.gate !== undefined) {
session.setGate(recipe.gate);
Expand Down
Loading
Loading