The visible AI terminal loop is buildable, but a credible replacement needs more than the first screen. Warp earns its keep through model, context, integration, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Warp. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Warp · indie build A block-based terminal whose hard problems are getting command boundaries from the shell instead of guessing at them, treating program output as hostile input to the renderer, and putting a tested redaction boundary in front of the one function that talks to a model. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with node-pty, bound to the loopback interface only | a real pseudoterminal is the only way a shell behaves like a shell | | Renderer | xterm.js in a browser tab served from localhost, with a locked-down sequence policy | you get a competent parser and you still decide what it honours | | Shell integration | A snippet the user installs that emits the OSC 133 prompt markers | the terminal cannot know where a command starts unless the shell tells it | | Storage | SQLite for block metadata, output bodies as files on disk under a retention | the history is the best feature here and also the thing you would least like copied | | Assistant | One request per explicit ask, through a single function that refuses unfiltered text | the model is the only part of this build that leaves the machine | | Redaction | Pattern rules plus a deny list of variable names, with an attack corpus in the suite | a redactor nobody has attacked is decoration | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the pty bridge and the build. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Which shell you will support first** · free - Why: The integration snippet is per shell, and supporting three badly is worse than supporting the one you actually use. - Get it: Pick the shell you run daily and write the snippet for its prompt hooks. - Verify: The snippet emits the markers and block rows appear - [ ] **A throwaway container or virtual machine** · free - Why: Phase 2 involves piping deliberately hostile output through the renderer, and you want that happening somewhere you do not mind breaking. - Get it: Any container runtime, or a virtual machine with a snapshot. - Verify: You can run the hostile fixture inside it and discard the result - [ ] **An API key for a text model** (optional) · pay per request; the ceiling in this build is what keeps it small - Why: The assistant is the only paid and only networked part of the build, and it is optional by design. - Get it: Create a key with your preferred provider and set a spend limit in their console as well. - Verify: One request returns a completion and writes an outbound row - [ ] **A corpus of fabricated credentials** · free - Why: The redactor is only as good as what you have attacked it with, and real credentials must never be the test data. - Get it: Write fake keys, headers, private key blocks and connection strings by hand into the fixtures directory. - Verify: The suite runs them and reports zero unmasked matches ## Quick start ```sh npm i node-pty @xterm/xterm ws ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. - IDE-wide polish and latency - enterprise policy, telemetry, and support - frontier coding model quality - large-context infrastructure If one of those is essential to you, that is the reason to keep paying for Warp, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Warp The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a terminal with command blocks and an assistant, where nothing leaves the machine unless you selected it and watched it go to replace Warp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A terminal sits between you and everything you have access to, and traffic runs both ways. Decide what you do about each direction before you write a line of it. Outbound: shell integration is not a cosmetic feature. The moment the shell emits the marker sequences, the terminal knows the exact command line you typed, the working directory you typed it in, the exit code, and the entire output block. That block is your keys when you run a command that prints the environment, your production hostnames when you run a deployment tool, the contents of a configuration file you happened to display, and the bearer token in a request you pasted. An assistant with a context window over that is an exfiltration channel with a friendly name. So: nothing reaches a model unless you selected the blocks and saw a preview of the exact bytes, redaction runs inside the one function that performs the request rather than in the interface, and every request writes a row recording the byte count and a hash of what was sent. The terminal is fully usable with the model switched off, and that is the default on first run. Inbound: everything a command prints is untrusted input that your renderer will interpret. xterm's own manual says of the extended window control sequences that each of these can be abused in a script, and that most terminal emulators which implement them restrict only a small part of the repertoire. Its allowWindowOps resource therefore defaults to false. Copy that posture rather than discovering it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with node-pty, bound to the loopback interface only · a real pseudoterminal is the only way a shell behaves like a shell - Renderer: xterm.js in a browser tab served from localhost, with a locked-down sequence policy · you get a competent parser and you still decide what it honours - Shell integration: A snippet the user installs that emits the OSC 133 prompt markers · the terminal cannot know where a command starts unless the shell tells it - Storage: SQLite for block metadata, output bodies as files on disk under a retention · the history is the best feature here and also the thing you would least like copied - Assistant: One request per explicit ask, through a single function that refuses unfiltered text · the model is the only part of this build that leaves the machine - Redaction: Pattern rules plus a deny list of variable names, with an attack corpus in the suite · a redactor nobody has attacked is decoration ### Data model (create this before Phase 1) - blocks(id, session_id, started_at, ended_at, command, cwd, exit_code, output_path, bytes, truncated) · one row per command, boundaries taken from the shell markers - sessions(id, shell, started_at, ended_at, tty, env_fingerprint) · env_fingerprint is a hash, never the variables themselves - rules(id, pattern_kind, pattern, label, enabled) · redaction rules, shipped with defaults and editable by hand - redactions(id, block_id, rule_id, span_start, span_end, label) · what was masked and by which rule, so a false positive is findable later - outbound(id, at, kind, block_ids_json, byte_count, sha256, model_id, cost_minor) · one row per request that left the machine - proposals(id, block_id, command_argv_json, rationale, accepted_at, rejected_at) · a suggested command is a row you can read, never a keystroke pushed into the pty - secrets_flagged(block_id, at, reason) · blocks excluded from the search index and from any selection offered to the model - No table here holds a model answer beside the raw output it read. The raw output stays in its block file and the answer is a proposal that points at it. ### Phase 1 · A real pseudoterminal, and blocks the shell tells you about Build: Spawn a login shell under node-pty, wire it to xterm.js over a websocket, and propagate window resizes to the pty. Ship a shell snippet that emits the OSC 133 markers: A at prompt start, B at prompt end, C before execution and D with the exit code afterwards. Turn those markers into block rows. Without the snippet installed, run as a plain terminal and say so in the interface. Trap: Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. Done when: With the snippet uninstalled the application works as an ordinary terminal and shows a visible notice that block capture is off, tested by launching with a clean profile. With it installed, a block row carries the exact command line, the working directory at execution time and the exit code, checked against a script that exits with code 3. Running a full-screen program such as an editor or a process viewer creates no spurious blocks, tested. Resizing the window resizes the pty, verified by printing the terminal size inside the shell after a resize. Do not build yet: No assistant, no redaction, no history search and no export yet. ### Phase 2 · Program output is hostile input Build: Write down which escape sequences the renderer honours and implement the rest as no-ops. Refuse selection manipulation, title and icon reporting, checksum reporting and property setting. Bracket every paste and neutralise control characters inside pasted text. Cap the bytes stored per block with a visible truncation marker, and ask before sending an unusually large paste. Trap: Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. Trap: A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. Done when: A crafted file containing selection, colour and title query sequences is displayed and produces zero bytes written back to the pty, asserted in a test. A paste containing a newline and an escape character is delivered bracketed with the control characters replaced, tested. Block output is capped at a configured byte count on disk and the truncation is visible in the interface. A paste above a configured size asks for confirmation before it reaches the shell. Do not build yet: No model calls, redaction or history search yet. ### Phase 3 · One door out, with a filter welded to it Build: Write one function that performs the model request. It accepts only text that carries a filter receipt, and it throws otherwise. The filter masks private key blocks, authorisation headers, connection strings, key-shaped tokens and the values of any variable on a deny list, recording each masked span. Every call writes an outbound row with the byte count and a hash of the payload. Trap: The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. Done when: Calling the outbound function with unfiltered text throws, asserted by calling it directly. A corpus of fabricated credentials covering a private key block, an authorisation header, a database connection string and a key-shaped token yields zero matches in the filtered output, tested. Every outbound request writes a row with byte count, hash, model identifier and cost. The preview shows the exact bytes that will be sent and refusing the preview cancels the request. Do not build yet: No assistant behaviour, history search or retention yet. ### Phase 4 · An assistant that proposes and never types Build: Send selected blocks and a question. Render any suggested command as a proposal row showing the fully expanded argument vector, with copy and an explicit run action. Nothing the model returns is ever written into the pty automatically. Classify destructive shapes and require the command to be retyped before those run. Trap: Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. Done when: No code path writes model output into the pty, asserted by a test that the write function is called only from the key and paste handlers. A proposal displays the expanded argument vector and running it requires a separate deliberate action, checked by review. A command matching the destructive classifier requires retyping before it runs, tested. With no model key configured the terminal starts and works, with the assistant panel absent rather than broken. Do not build yet: No search, export, retention or spend ceiling yet. ### Phase 5 · History you own, and a bill that stops Build: Full-text search over commands and output, excluding flagged blocks. Export every block as JSONL with its output files. Delete output bodies after a retention while keeping the rows. Cap the monthly model spend. Generate a report from the outbound table listing every request that left the machine, with dates, byte counts and hashes. Trap: A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. Done when: Search returns matches from commands and output and never returns a flagged block, tested. Export produces JSONL plus output files and reimports into an empty database with identical block counts and hashes, tested. Output bodies older than the retention are deleted and their rows survive with command, exit code and byte count intact, tested. Reaching the monthly ceiling disables the assistant with a message and leaves the terminal working. Do not build yet: Nothing after this. ### Out of scope (and why) - Team workflows and shared history, which is most of what the subscription sells and means holding other people's shell sessions on your server. - An agent that runs commands unattended, because the input it reasons over is program output and program output is written by whatever you just ran. - Recording sessions on remote hosts over SSH, which needs the integration installed there and quietly doubles the blast radius. - A native GPU renderer and the latency work that goes with it, which is genuinely months and is the thing people notice first. ### README must contain - Exactly what the shell integration observes: command line, working directory, exit code and the whole output block - What leaves the machine, where the outbound log lives, and how to read it - That the assistant is off until a key is configured, and how to keep it off - The escape sequence policy and why title, selection and checksum reporting are implemented as no-ops - Where the history and output files live, how long they are kept, and the command that deletes them ===== AGENTS.md ===== # Agent instructions · Warp indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with node-pty, bound to the loopback interface only, xterm.js in a browser tab served from localhost, with a locked-down sequence policy, A snippet the user installs that emits the OSC 133 prompt markers, SQLite for block metadata, output bodies as files on disk under a retention, One request per explicit ask, through a single function that refuses unfiltered text, Pattern rules plus a deny list of variable names, with an attack corpus in the suite. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ===== BUILD_PLAN.md ===== # Build plan · Warp A block-based terminal whose hard problems are getting command boundaries from the shell instead of guessing at them, treating program output as hostile input to the renderer, and putting a tested redaction boundary in front of the one function that talks to a model. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A real pseudoterminal, and blocks the shell tells you about Commands, working directories and exit codes come from the shell rather than from pattern matching on the screen. ### Steps 1. Spawn a pseudoterminal, not a pipe A shell attached to a pipe disables its prompt, its job control and its colour. Files: `src/pty.ts`, `src/server.ts` ```sh npm i node-pty @xterm/xterm ws ``` 2. Take block boundaries from the OSC 133 markers The shell knows where a command starts and the screen does not. Files: `shell/integration.sh`, `src/blocks.ts`, `tests/blocks.test.ts` 3. Degrade to a plain terminal when the snippet is absent Silently guessing is worse than visibly not knowing. Files: `src/blocks.ts`, `src/ui/Banner.tsx` ### Done when - [ ] Without the snippet it runs as a plain terminal and says so - [ ] Command, working directory and exit code are captured exactly - [ ] A full-screen program creates no spurious blocks - [ ] A window resize reaches the pty ### Watch out - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. ## Phase 2 · Program output is hostile input Displaying a file somebody sent you cannot make the terminal answer back, set your clipboard or report your window title. ### Steps 1. Write down the sequence policy and implement the rest as no-ops A list you can read beats whatever the parser happened to support. Files: `src/sequences.ts`, `docs/sequence-policy.md` 2. Feed the renderer a hostile file in the test suite Assert that nothing travels back up the pty. Files: `tests/hostile-output.test.ts`, `tests/fixtures/hostile.bin` 3. Bracket every paste and filter its control characters The clipboard is somebody else's text arriving at a shell prompt. Files: `src/paste.ts`, `tests/paste.test.ts` ### Done when - [ ] Hostile output produces no response bytes - [ ] Pastes are bracketed and control characters replaced - [ ] Block output is capped with a visible truncation marker - [ ] A large paste asks before it is sent ### Watch out - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. ## Phase 3 · One door out, with a filter welded to it Text cannot reach the network without passing the redactor, and every request that left is recorded. ### Steps 1. Make the request function refuse unfiltered text One door, and the lock is on the door rather than on the corridor. Files: `src/outbound.ts`, `tests/outbound.test.ts` 2. Keep an attack corpus in the repository Fabricated credentials only, and the suite runs them on every change. Files: `tests/fixtures/credentials/`, `tests/redaction.test.ts` 3. Record byte count and hash for every request So the question of what left this machine has an answer. Files: `src/outbound.ts`, `src/db/outbound.ts` ### Done when - [ ] Unfiltered text cannot reach the network - [ ] The credential corpus is fully masked - [ ] Every request writes an outbound row - [ ] The preview shows the exact bytes and can cancel ### Watch out - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. ## Phase 4 · An assistant that proposes and never types A suggested command is inert text until a person deliberately runs it. ### Steps 1. Keep the pty write path reachable from two places only Key handling and paste. Everything else is a proposal. Files: `src/pty.ts`, `tests/no-injection.test.ts` 2. Show the argument vector, not a string A pipe hidden in a suggestion is the whole attack. Files: `src/ui/Proposal.tsx` 3. Make the model optional at startup The default first run has no key and is a good terminal. Files: `src/config.ts`, `README.md` ### Done when - [ ] Model output never reaches the pty - [ ] Proposals show the expanded argument vector - [ ] Destructive commands require retyping - [ ] The terminal runs with no model configured ### Watch out - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ## Not in this build - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. ## After v1, if you want it - Per-directory profiles, so a production checkout can force the assistant off entirely - A second shell integration snippet, once the first one has survived a month of real use - Encrypting the output directory at rest, which is the obvious next thing after the retention policy ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. DATABASE_PATH=./data/terminal.db # Required. Block output bodies on disk. Expired on a schedule, rows retained. OUTPUT_DIR=./data/output # Required. How long output bodies survive. Shorter is the sane default for a laptop. OUTPUT_RETENTION_DAYS=14 # Required. Per-block storage cap. Above it the block is truncated visibly. MAX_BLOCK_BYTES=262144 # Required. Variable names whose values are masked wherever they appear in output. REDACTION_DENY_VARS=AWS_SECRET_ACCESS_KEY,DATABASE_URL,GITHUB_TOKEN # Optional · secret. Absent by default. Without it the assistant panel is not shown. MODEL_API_KEY=sk-...redacted # Optional. Recorded on every outbound row, because answers differ by model. MODEL_ID=a text model identifier # Required. The assistant stops at this figure and the terminal carries on. MONTHLY_COST_CEILING=5.00
You are building a lean indie version of Warp. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Warp · indie build A block-based terminal whose hard problems are getting command boundaries from the shell instead of guessing at them, treating program output as hostile input to the renderer, and putting a tested redaction boundary in front of the one function that talks to a model. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with node-pty, bound to the loopback interface only | a real pseudoterminal is the only way a shell behaves like a shell | | Renderer | xterm.js in a browser tab served from localhost, with a locked-down sequence policy | you get a competent parser and you still decide what it honours | | Shell integration | A snippet the user installs that emits the OSC 133 prompt markers | the terminal cannot know where a command starts unless the shell tells it | | Storage | SQLite for block metadata, output bodies as files on disk under a retention | the history is the best feature here and also the thing you would least like copied | | Assistant | One request per explicit ask, through a single function that refuses unfiltered text | the model is the only part of this build that leaves the machine | | Redaction | Pattern rules plus a deny list of variable names, with an attack corpus in the suite | a redactor nobody has attacked is decoration | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the pty bridge and the build. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Which shell you will support first** · free - Why: The integration snippet is per shell, and supporting three badly is worse than supporting the one you actually use. - Get it: Pick the shell you run daily and write the snippet for its prompt hooks. - Verify: The snippet emits the markers and block rows appear - [ ] **A throwaway container or virtual machine** · free - Why: Phase 2 involves piping deliberately hostile output through the renderer, and you want that happening somewhere you do not mind breaking. - Get it: Any container runtime, or a virtual machine with a snapshot. - Verify: You can run the hostile fixture inside it and discard the result - [ ] **An API key for a text model** (optional) · pay per request; the ceiling in this build is what keeps it small - Why: The assistant is the only paid and only networked part of the build, and it is optional by design. - Get it: Create a key with your preferred provider and set a spend limit in their console as well. - Verify: One request returns a completion and writes an outbound row - [ ] **A corpus of fabricated credentials** · free - Why: The redactor is only as good as what you have attacked it with, and real credentials must never be the test data. - Get it: Write fake keys, headers, private key blocks and connection strings by hand into the fixtures directory. - Verify: The suite runs them and reports zero unmasked matches ## Quick start ```sh npm i node-pty @xterm/xterm ws ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. - IDE-wide polish and latency - enterprise policy, telemetry, and support - frontier coding model quality - large-context infrastructure If one of those is essential to you, that is the reason to keep paying for Warp, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Warp The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a terminal with command blocks and an assistant, where nothing leaves the machine unless you selected it and watched it go to replace Warp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A terminal sits between you and everything you have access to, and traffic runs both ways. Decide what you do about each direction before you write a line of it. Outbound: shell integration is not a cosmetic feature. The moment the shell emits the marker sequences, the terminal knows the exact command line you typed, the working directory you typed it in, the exit code, and the entire output block. That block is your keys when you run a command that prints the environment, your production hostnames when you run a deployment tool, the contents of a configuration file you happened to display, and the bearer token in a request you pasted. An assistant with a context window over that is an exfiltration channel with a friendly name. So: nothing reaches a model unless you selected the blocks and saw a preview of the exact bytes, redaction runs inside the one function that performs the request rather than in the interface, and every request writes a row recording the byte count and a hash of what was sent. The terminal is fully usable with the model switched off, and that is the default on first run. Inbound: everything a command prints is untrusted input that your renderer will interpret. xterm's own manual says of the extended window control sequences that each of these can be abused in a script, and that most terminal emulators which implement them restrict only a small part of the repertoire. Its allowWindowOps resource therefore defaults to false. Copy that posture rather than discovering it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with node-pty, bound to the loopback interface only · a real pseudoterminal is the only way a shell behaves like a shell - Renderer: xterm.js in a browser tab served from localhost, with a locked-down sequence policy · you get a competent parser and you still decide what it honours - Shell integration: A snippet the user installs that emits the OSC 133 prompt markers · the terminal cannot know where a command starts unless the shell tells it - Storage: SQLite for block metadata, output bodies as files on disk under a retention · the history is the best feature here and also the thing you would least like copied - Assistant: One request per explicit ask, through a single function that refuses unfiltered text · the model is the only part of this build that leaves the machine - Redaction: Pattern rules plus a deny list of variable names, with an attack corpus in the suite · a redactor nobody has attacked is decoration ### Data model (create this before Phase 1) - blocks(id, session_id, started_at, ended_at, command, cwd, exit_code, output_path, bytes, truncated) · one row per command, boundaries taken from the shell markers - sessions(id, shell, started_at, ended_at, tty, env_fingerprint) · env_fingerprint is a hash, never the variables themselves - rules(id, pattern_kind, pattern, label, enabled) · redaction rules, shipped with defaults and editable by hand - redactions(id, block_id, rule_id, span_start, span_end, label) · what was masked and by which rule, so a false positive is findable later - outbound(id, at, kind, block_ids_json, byte_count, sha256, model_id, cost_minor) · one row per request that left the machine - proposals(id, block_id, command_argv_json, rationale, accepted_at, rejected_at) · a suggested command is a row you can read, never a keystroke pushed into the pty - secrets_flagged(block_id, at, reason) · blocks excluded from the search index and from any selection offered to the model - No table here holds a model answer beside the raw output it read. The raw output stays in its block file and the answer is a proposal that points at it. ### Phase 1 · A real pseudoterminal, and blocks the shell tells you about Build: Spawn a login shell under node-pty, wire it to xterm.js over a websocket, and propagate window resizes to the pty. Ship a shell snippet that emits the OSC 133 markers: A at prompt start, B at prompt end, C before execution and D with the exit code afterwards. Turn those markers into block rows. Without the snippet installed, run as a plain terminal and say so in the interface. Trap: Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. Done when: With the snippet uninstalled the application works as an ordinary terminal and shows a visible notice that block capture is off, tested by launching with a clean profile. With it installed, a block row carries the exact command line, the working directory at execution time and the exit code, checked against a script that exits with code 3. Running a full-screen program such as an editor or a process viewer creates no spurious blocks, tested. Resizing the window resizes the pty, verified by printing the terminal size inside the shell after a resize. Do not build yet: No assistant, no redaction, no history search and no export yet. ### Phase 2 · Program output is hostile input Build: Write down which escape sequences the renderer honours and implement the rest as no-ops. Refuse selection manipulation, title and icon reporting, checksum reporting and property setting. Bracket every paste and neutralise control characters inside pasted text. Cap the bytes stored per block with a visible truncation marker, and ask before sending an unusually large paste. Trap: Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. Trap: A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. Done when: A crafted file containing selection, colour and title query sequences is displayed and produces zero bytes written back to the pty, asserted in a test. A paste containing a newline and an escape character is delivered bracketed with the control characters replaced, tested. Block output is capped at a configured byte count on disk and the truncation is visible in the interface. A paste above a configured size asks for confirmation before it reaches the shell. Do not build yet: No model calls, redaction or history search yet. ### Phase 3 · One door out, with a filter welded to it Build: Write one function that performs the model request. It accepts only text that carries a filter receipt, and it throws otherwise. The filter masks private key blocks, authorisation headers, connection strings, key-shaped tokens and the values of any variable on a deny list, recording each masked span. Every call writes an outbound row with the byte count and a hash of the payload. Trap: The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. Done when: Calling the outbound function with unfiltered text throws, asserted by calling it directly. A corpus of fabricated credentials covering a private key block, an authorisation header, a database connection string and a key-shaped token yields zero matches in the filtered output, tested. Every outbound request writes a row with byte count, hash, model identifier and cost. The preview shows the exact bytes that will be sent and refusing the preview cancels the request. Do not build yet: No assistant behaviour, history search or retention yet. ### Phase 4 · An assistant that proposes and never types Build: Send selected blocks and a question. Render any suggested command as a proposal row showing the fully expanded argument vector, with copy and an explicit run action. Nothing the model returns is ever written into the pty automatically. Classify destructive shapes and require the command to be retyped before those run. Trap: Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. Done when: No code path writes model output into the pty, asserted by a test that the write function is called only from the key and paste handlers. A proposal displays the expanded argument vector and running it requires a separate deliberate action, checked by review. A command matching the destructive classifier requires retyping before it runs, tested. With no model key configured the terminal starts and works, with the assistant panel absent rather than broken. Do not build yet: No search, export, retention or spend ceiling yet. ### Phase 5 · History you own, and a bill that stops Build: Full-text search over commands and output, excluding flagged blocks. Export every block as JSONL with its output files. Delete output bodies after a retention while keeping the rows. Cap the monthly model spend. Generate a report from the outbound table listing every request that left the machine, with dates, byte counts and hashes. Trap: A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. Done when: Search returns matches from commands and output and never returns a flagged block, tested. Export produces JSONL plus output files and reimports into an empty database with identical block counts and hashes, tested. Output bodies older than the retention are deleted and their rows survive with command, exit code and byte count intact, tested. Reaching the monthly ceiling disables the assistant with a message and leaves the terminal working. Do not build yet: Nothing after this. ### Out of scope (and why) - Team workflows and shared history, which is most of what the subscription sells and means holding other people's shell sessions on your server. - An agent that runs commands unattended, because the input it reasons over is program output and program output is written by whatever you just ran. - Recording sessions on remote hosts over SSH, which needs the integration installed there and quietly doubles the blast radius. - A native GPU renderer and the latency work that goes with it, which is genuinely months and is the thing people notice first. ### README must contain - Exactly what the shell integration observes: command line, working directory, exit code and the whole output block - What leaves the machine, where the outbound log lives, and how to read it - That the assistant is off until a key is configured, and how to keep it off - The escape sequence policy and why title, selection and checksum reporting are implemented as no-ops - Where the history and output files live, how long they are kept, and the command that deletes them ===== AGENTS.md ===== # Agent instructions · Warp indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with node-pty, bound to the loopback interface only, xterm.js in a browser tab served from localhost, with a locked-down sequence policy, A snippet the user installs that emits the OSC 133 prompt markers, SQLite for block metadata, output bodies as files on disk under a retention, One request per explicit ask, through a single function that refuses unfiltered text, Pattern rules plus a deny list of variable names, with an attack corpus in the suite. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ===== BUILD_PLAN.md ===== # Build plan · Warp A block-based terminal whose hard problems are getting command boundaries from the shell instead of guessing at them, treating program output as hostile input to the renderer, and putting a tested redaction boundary in front of the one function that talks to a model. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A real pseudoterminal, and blocks the shell tells you about Commands, working directories and exit codes come from the shell rather than from pattern matching on the screen. ### Steps 1. Spawn a pseudoterminal, not a pipe A shell attached to a pipe disables its prompt, its job control and its colour. Files: `src/pty.ts`, `src/server.ts` ```sh npm i node-pty @xterm/xterm ws ``` 2. Take block boundaries from the OSC 133 markers The shell knows where a command starts and the screen does not. Files: `shell/integration.sh`, `src/blocks.ts`, `tests/blocks.test.ts` 3. Degrade to a plain terminal when the snippet is absent Silently guessing is worse than visibly not knowing. Files: `src/blocks.ts`, `src/ui/Banner.tsx` ### Done when - [ ] Without the snippet it runs as a plain terminal and says so - [ ] Command, working directory and exit code are captured exactly - [ ] A full-screen program creates no spurious blocks - [ ] A window resize reaches the pty ### Watch out - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. ## Phase 2 · Program output is hostile input Displaying a file somebody sent you cannot make the terminal answer back, set your clipboard or report your window title. ### Steps 1. Write down the sequence policy and implement the rest as no-ops A list you can read beats whatever the parser happened to support. Files: `src/sequences.ts`, `docs/sequence-policy.md` 2. Feed the renderer a hostile file in the test suite Assert that nothing travels back up the pty. Files: `tests/hostile-output.test.ts`, `tests/fixtures/hostile.bin` 3. Bracket every paste and filter its control characters The clipboard is somebody else's text arriving at a shell prompt. Files: `src/paste.ts`, `tests/paste.test.ts` ### Done when - [ ] Hostile output produces no response bytes - [ ] Pastes are bracketed and control characters replaced - [ ] Block output is capped with a visible truncation marker - [ ] A large paste asks before it is sent ### Watch out - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. ## Phase 3 · One door out, with a filter welded to it Text cannot reach the network without passing the redactor, and every request that left is recorded. ### Steps 1. Make the request function refuse unfiltered text One door, and the lock is on the door rather than on the corridor. Files: `src/outbound.ts`, `tests/outbound.test.ts` 2. Keep an attack corpus in the repository Fabricated credentials only, and the suite runs them on every change. Files: `tests/fixtures/credentials/`, `tests/redaction.test.ts` 3. Record byte count and hash for every request So the question of what left this machine has an answer. Files: `src/outbound.ts`, `src/db/outbound.ts` ### Done when - [ ] Unfiltered text cannot reach the network - [ ] The credential corpus is fully masked - [ ] Every request writes an outbound row - [ ] The preview shows the exact bytes and can cancel ### Watch out - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. ## Phase 4 · An assistant that proposes and never types A suggested command is inert text until a person deliberately runs it. ### Steps 1. Keep the pty write path reachable from two places only Key handling and paste. Everything else is a proposal. Files: `src/pty.ts`, `tests/no-injection.test.ts` 2. Show the argument vector, not a string A pipe hidden in a suggestion is the whole attack. Files: `src/ui/Proposal.tsx` 3. Make the model optional at startup The default first run has no key and is a good terminal. Files: `src/config.ts`, `README.md` ### Done when - [ ] Model output never reaches the pty - [ ] Proposals show the expanded argument vector - [ ] Destructive commands require retyping - [ ] The terminal runs with no model configured ### Watch out - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ## Not in this build - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. ## After v1, if you want it - Per-directory profiles, so a production checkout can force the assistant off entirely - A second shell integration snippet, once the first one has survived a month of real use - Encrypting the output directory at rest, which is the obvious next thing after the retention policy ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. DATABASE_PATH=./data/terminal.db # Required. Block output bodies on disk. Expired on a schedule, rows retained. OUTPUT_DIR=./data/output # Required. How long output bodies survive. Shorter is the sane default for a laptop. OUTPUT_RETENTION_DAYS=14 # Required. Per-block storage cap. Above it the block is truncated visibly. MAX_BLOCK_BYTES=262144 # Required. Variable names whose values are masked wherever they appear in output. REDACTION_DENY_VARS=AWS_SECRET_ACCESS_KEY,DATABASE_URL,GITHUB_TOKEN # Optional · secret. Absent by default. Without it the assistant panel is not shown. MODEL_API_KEY=sk-...redacted # Optional. Recorded on every outbound row, because answers differ by model. MODEL_ID=a text model identifier # Required. The assistant stops at this figure and the terminal carries on. MONTHLY_COST_CEILING=5.00
You are building a production product version of Warp. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Warp · product brief ## Problem The visible AI terminal loop is buildable, but a credible replacement needs more than the first screen. Warp earns its keep through model, context, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A terminal that gets its command boundaries from the shell, treats program output as hostile, keeps a searchable local history that expires, and can tell you precisely what bytes ever left the machine. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Python 3.12 - Git - OpenAI API key in .env - a disposable test repository ## Explicit non-goals for v1 - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. - IDE-wide polish and latency - enterprise policy, telemetry, and support - frontier coding model quality - large-context infrastructure ## Success criteria - Hostile output is confirmed to produce no response bytes - The outbound function is confirmed to reject unfiltered text - Model output is confirmed never to reach the pty - Flagged blocks are confirmed absent from the search index - The terminal is confirmed to run with no model key configured ===== BRIEF.md ===== # Build brief · Warp The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a terminal with command blocks and an assistant, where nothing leaves the machine unless you selected it and watched it go to replace Warp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A terminal sits between you and everything you have access to, and traffic runs both ways. Decide what you do about each direction before you write a line of it. Outbound: shell integration is not a cosmetic feature. The moment the shell emits the marker sequences, the terminal knows the exact command line you typed, the working directory you typed it in, the exit code, and the entire output block. That block is your keys when you run a command that prints the environment, your production hostnames when you run a deployment tool, the contents of a configuration file you happened to display, and the bearer token in a request you pasted. An assistant with a context window over that is an exfiltration channel with a friendly name. So: nothing reaches a model unless you selected the blocks and saw a preview of the exact bytes, redaction runs inside the one function that performs the request rather than in the interface, and every request writes a row recording the byte count and a hash of what was sent. The terminal is fully usable with the model switched off, and that is the default on first run. Inbound: everything a command prints is untrusted input that your renderer will interpret. xterm's own manual says of the extended window control sequences that each of these can be abused in a script, and that most terminal emulators which implement them restrict only a small part of the repertoire. Its allowWindowOps resource therefore defaults to false. Copy that posture rather than discovering it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with node-pty, bound to the loopback interface only · a real pseudoterminal is the only way a shell behaves like a shell - Renderer: xterm.js in a browser tab served from localhost, with a locked-down sequence policy · you get a competent parser and you still decide what it honours - Shell integration: A snippet the user installs that emits the OSC 133 prompt markers · the terminal cannot know where a command starts unless the shell tells it - Storage: SQLite for block metadata, output bodies as files on disk under a retention · the history is the best feature here and also the thing you would least like copied - Assistant: One request per explicit ask, through a single function that refuses unfiltered text · the model is the only part of this build that leaves the machine - Redaction: Pattern rules plus a deny list of variable names, with an attack corpus in the suite · a redactor nobody has attacked is decoration ### Data model (create this before Phase 1) - blocks(id, session_id, started_at, ended_at, command, cwd, exit_code, output_path, bytes, truncated) · one row per command, boundaries taken from the shell markers - sessions(id, shell, started_at, ended_at, tty, env_fingerprint) · env_fingerprint is a hash, never the variables themselves - rules(id, pattern_kind, pattern, label, enabled) · redaction rules, shipped with defaults and editable by hand - redactions(id, block_id, rule_id, span_start, span_end, label) · what was masked and by which rule, so a false positive is findable later - outbound(id, at, kind, block_ids_json, byte_count, sha256, model_id, cost_minor) · one row per request that left the machine - proposals(id, block_id, command_argv_json, rationale, accepted_at, rejected_at) · a suggested command is a row you can read, never a keystroke pushed into the pty - secrets_flagged(block_id, at, reason) · blocks excluded from the search index and from any selection offered to the model - No table here holds a model answer beside the raw output it read. The raw output stays in its block file and the answer is a proposal that points at it. ### Phase 1 · A real pseudoterminal, and blocks the shell tells you about Build: Spawn a login shell under node-pty, wire it to xterm.js over a websocket, and propagate window resizes to the pty. Ship a shell snippet that emits the OSC 133 markers: A at prompt start, B at prompt end, C before execution and D with the exit code afterwards. Turn those markers into block rows. Without the snippet installed, run as a plain terminal and say so in the interface. Trap: Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. Done when: With the snippet uninstalled the application works as an ordinary terminal and shows a visible notice that block capture is off, tested by launching with a clean profile. With it installed, a block row carries the exact command line, the working directory at execution time and the exit code, checked against a script that exits with code 3. Running a full-screen program such as an editor or a process viewer creates no spurious blocks, tested. Resizing the window resizes the pty, verified by printing the terminal size inside the shell after a resize. Do not build yet: No assistant, no redaction, no history search and no export yet. ### Phase 2 · Program output is hostile input Build: Write down which escape sequences the renderer honours and implement the rest as no-ops. Refuse selection manipulation, title and icon reporting, checksum reporting and property setting. Bracket every paste and neutralise control characters inside pasted text. Cap the bytes stored per block with a visible truncation marker, and ask before sending an unusually large paste. Trap: Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. Trap: A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. Done when: A crafted file containing selection, colour and title query sequences is displayed and produces zero bytes written back to the pty, asserted in a test. A paste containing a newline and an escape character is delivered bracketed with the control characters replaced, tested. Block output is capped at a configured byte count on disk and the truncation is visible in the interface. A paste above a configured size asks for confirmation before it reaches the shell. Do not build yet: No model calls, redaction or history search yet. ### Phase 3 · One door out, with a filter welded to it Build: Write one function that performs the model request. It accepts only text that carries a filter receipt, and it throws otherwise. The filter masks private key blocks, authorisation headers, connection strings, key-shaped tokens and the values of any variable on a deny list, recording each masked span. Every call writes an outbound row with the byte count and a hash of the payload. Trap: The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. Done when: Calling the outbound function with unfiltered text throws, asserted by calling it directly. A corpus of fabricated credentials covering a private key block, an authorisation header, a database connection string and a key-shaped token yields zero matches in the filtered output, tested. Every outbound request writes a row with byte count, hash, model identifier and cost. The preview shows the exact bytes that will be sent and refusing the preview cancels the request. Do not build yet: No assistant behaviour, history search or retention yet. ### Phase 4 · An assistant that proposes and never types Build: Send selected blocks and a question. Render any suggested command as a proposal row showing the fully expanded argument vector, with copy and an explicit run action. Nothing the model returns is ever written into the pty automatically. Classify destructive shapes and require the command to be retyped before those run. Trap: Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. Done when: No code path writes model output into the pty, asserted by a test that the write function is called only from the key and paste handlers. A proposal displays the expanded argument vector and running it requires a separate deliberate action, checked by review. A command matching the destructive classifier requires retyping before it runs, tested. With no model key configured the terminal starts and works, with the assistant panel absent rather than broken. Do not build yet: No search, export, retention or spend ceiling yet. ### Phase 5 · History you own, and a bill that stops Build: Full-text search over commands and output, excluding flagged blocks. Export every block as JSONL with its output files. Delete output bodies after a retention while keeping the rows. Cap the monthly model spend. Generate a report from the outbound table listing every request that left the machine, with dates, byte counts and hashes. Trap: A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. Done when: Search returns matches from commands and output and never returns a flagged block, tested. Export produces JSONL plus output files and reimports into an empty database with identical block counts and hashes, tested. Output bodies older than the retention are deleted and their rows survive with command, exit code and byte count intact, tested. Reaching the monthly ceiling disables the assistant with a message and leaves the terminal working. Do not build yet: Nothing after this. ### Out of scope (and why) - Team workflows and shared history, which is most of what the subscription sells and means holding other people's shell sessions on your server. - An agent that runs commands unattended, because the input it reasons over is program output and program output is written by whatever you just ran. - Recording sessions on remote hosts over SSH, which needs the integration installed there and quietly doubles the blast radius. - A native GPU renderer and the latency work that goes with it, which is genuinely months and is the thing people notice first. ### README must contain - Exactly what the shell integration observes: command line, working directory, exit code and the whole output block - What leaves the machine, where the outbound log lives, and how to read it - That the assistant is off until a key is configured, and how to keep it off - The escape sequence policy and why title, selection and checksum reporting are implemented as no-ops - Where the history and output files live, how long they are kept, and the command that deletes them ===== ARCHITECTURE.md ===== # Architecture · Warp ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with node-pty, bound to the loopback interface only | a real pseudoterminal is the only way a shell behaves like a shell | | Renderer | xterm.js in a browser tab served from localhost, with a locked-down sequence policy | you get a competent parser and you still decide what it honours | | Shell integration | A snippet the user installs that emits the OSC 133 prompt markers | the terminal cannot know where a command starts unless the shell tells it | | Storage | SQLite for block metadata, output bodies as files on disk under a retention | the history is the best feature here and also the thing you would least like copied | | Assistant | One request per explicit ask, through a single function that refuses unfiltered text | the model is the only part of this build that leaves the machine | | Redaction | Pattern rules plus a deny list of variable names, with an attack corpus in the suite | a redactor nobody has attacked is decoration | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Pty bridge | Process spawn, resize, and the only write path to the shell. | Any pty binding, provided the write path stays reachable from two call sites. | | Blocks | Marker parsing and block rows. | Any marker scheme the shell can emit. Not swappable for screen scraping. | | Sequences | What the renderer honours and what it ignores. | Any renderer, provided the reporting sequences stay no-ops. | | Outbound | Redaction, the single request function and the log. | Any provider, provided the filter stays inside the request function. | | Archive | Search, export, retention and the outbound report. | Any index, provided flagged blocks are excluded from it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. - `OUTPUT_DIR` · required · Block output bodies on disk. Expired on a schedule, rows retained. - `OUTPUT_RETENTION_DAYS` · required · How long output bodies survive. Shorter is the sane default for a laptop. - `MAX_BLOCK_BYTES` · required · Per-block storage cap. Above it the block is truncated visibly. - `REDACTION_DENY_VARS` · required · Variable names whose values are masked wherever they appear in output. - `MODEL_API_KEY` · optional, secret · Absent by default. Without it the assistant panel is not shown. - `MODEL_ID` · optional · Recorded on every outbound row, because answers differ by model. - `MONTHLY_COST_CEILING` · required · The assistant stops at this figure and the terminal carries on. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Warp product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with node-pty, bound to the loopback interface only, xterm.js in a browser tab served from localhost, with a locked-down sequence policy, A snippet the user installs that emits the OSC 133 prompt markers, SQLite for block metadata, output bodies as files on disk under a retention, One request per explicit ask, through a single function that refuses unfiltered text, Pattern rules plus a deny list of variable names, with an attack corpus in the suite. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. - A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. ===== MILESTONES.md ===== # Delivery milestones · Warp Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A real pseudoterminal, and blocks the shell tells you about Commands, working directories and exit codes come from the shell rather than from pattern matching on the screen. ### Steps 1. Spawn a pseudoterminal, not a pipe A shell attached to a pipe disables its prompt, its job control and its colour. Files: `src/pty.ts`, `src/server.ts` ```sh npm i node-pty @xterm/xterm ws ``` 2. Take block boundaries from the OSC 133 markers The shell knows where a command starts and the screen does not. Files: `shell/integration.sh`, `src/blocks.ts`, `tests/blocks.test.ts` 3. Degrade to a plain terminal when the snippet is absent Silently guessing is worse than visibly not knowing. Files: `src/blocks.ts`, `src/ui/Banner.tsx` ### Done when - [ ] Without the snippet it runs as a plain terminal and says so - [ ] Command, working directory and exit code are captured exactly - [ ] A full-screen program creates no spurious blocks - [ ] A window resize reaches the pty ### Watch out - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. ## M2 · Program output is hostile input Displaying a file somebody sent you cannot make the terminal answer back, set your clipboard or report your window title. ### Steps 1. Write down the sequence policy and implement the rest as no-ops A list you can read beats whatever the parser happened to support. Files: `src/sequences.ts`, `docs/sequence-policy.md` 2. Feed the renderer a hostile file in the test suite Assert that nothing travels back up the pty. Files: `tests/hostile-output.test.ts`, `tests/fixtures/hostile.bin` 3. Bracket every paste and filter its control characters The clipboard is somebody else's text arriving at a shell prompt. Files: `src/paste.ts`, `tests/paste.test.ts` ### Done when - [ ] Hostile output produces no response bytes - [ ] Pastes are bracketed and control characters replaced - [ ] Block output is capped with a visible truncation marker - [ ] A large paste asks before it is sent ### Watch out - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. ## M3 · One door out, with a filter welded to it Text cannot reach the network without passing the redactor, and every request that left is recorded. ### Steps 1. Make the request function refuse unfiltered text One door, and the lock is on the door rather than on the corridor. Files: `src/outbound.ts`, `tests/outbound.test.ts` 2. Keep an attack corpus in the repository Fabricated credentials only, and the suite runs them on every change. Files: `tests/fixtures/credentials/`, `tests/redaction.test.ts` 3. Record byte count and hash for every request So the question of what left this machine has an answer. Files: `src/outbound.ts`, `src/db/outbound.ts` ### Done when - [ ] Unfiltered text cannot reach the network - [ ] The credential corpus is fully masked - [ ] Every request writes an outbound row - [ ] The preview shows the exact bytes and can cancel ### Watch out - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. ## M4 · An assistant that proposes and never types A suggested command is inert text until a person deliberately runs it. ### Steps 1. Keep the pty write path reachable from two places only Key handling and paste. Everything else is a proposal. Files: `src/pty.ts`, `tests/no-injection.test.ts` 2. Show the argument vector, not a string A pipe hidden in a suggestion is the whole attack. Files: `src/ui/Proposal.tsx` 3. Make the model optional at startup The default first run has no key and is a good terminal. Files: `src/config.ts`, `README.md` ### Done when - [ ] Model output never reaches the pty - [ ] Proposals show the expanded argument vector - [ ] Destructive commands require retyping - [ ] The terminal runs with no model configured ### Watch out - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ## M5 · History you own, and a bill that stops (production only) The archive is searchable, exportable, expiring, and the spend has a ceiling. ### Steps 1. Exclude flagged blocks from the index A secret you masked in transit is still on disk unless you say otherwise. Files: `src/search.ts`, `tests/search-exclusion.test.ts` 2. Expire output bodies, keep the rows The command history stays useful long after the output stops being. Files: `src/retention.ts` 3. Generate the outbound report from the table Written from data rather than from memory of what you built. Files: `src/reports/outbound.ts`, `README.md` ### Done when - [ ] Flagged blocks never appear in search results - [ ] Export and reimport round-trips blocks and hashes - [ ] Output bodies expire and rows survive - [ ] The spend ceiling stops the assistant, not the terminal ### Watch out - A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. ===== OPERATIONS.md ===== # Operations · Warp ## Backup The database holds the command history and the outbound log; the output directory holds bodies that expire anyway. Back up the database, and decide consciously whether you want a backup of your shell history existing at all. ## Restore Restore the database, confirm block counts and that export hashes match, then decide whether to restore output bodies or let them stay gone. A restore that resurrects expired output has quietly undone the retention policy. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Monthly model spend against the ceiling, the count of outbound rows per week, the rate of redaction matches, and whether the hostile-output test still passes after any renderer upgrade. A jump in outbound rows usually means a new code path found the request function. ## Incident checklist The incident here is a secret that left. The outbound table gives you the date, the byte count and the hash of every payload, and the redactions table tells you which rules fired on the blocks involved, which is the difference between rotating one credential and rotating all of them. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Hostile output is confirmed to produce no response bytes - [ ] The outbound function is confirmed to reject unfiltered text - [ ] Model output is confirmed never to reach the pty - [ ] Flagged blocks are confirmed absent from the search index - [ ] The terminal is confirmed to run with no model key configured ## Launch constraint Do not market omitted Warp capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. DATABASE_PATH=./data/terminal.db # Required. Block output bodies on disk. Expired on a schedule, rows retained. OUTPUT_DIR=./data/output # Required. How long output bodies survive. Shorter is the sane default for a laptop. OUTPUT_RETENTION_DAYS=14 # Required. Per-block storage cap. Above it the block is truncated visibly. MAX_BLOCK_BYTES=262144 # Required. Variable names whose values are masked wherever they appear in output. REDACTION_DENY_VARS=AWS_SECRET_ACCESS_KEY,DATABASE_URL,GITHUB_TOKEN # Optional · secret. Absent by default. Without it the assistant panel is not shown. MODEL_API_KEY=sk-...redacted # Optional. Recorded on every outbound row, because answers differ by model. MODEL_ID=a text model identifier # Required. The assistant stops at this figure and the terminal carries on. MONTHLY_COST_CEILING=5.00
# Warp · indie build A block-based terminal whose hard problems are getting command boundaries from the shell instead of guessing at them, treating program output as hostile input to the renderer, and putting a tested redaction boundary in front of the one function that talks to a model. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with node-pty, bound to the loopback interface only | a real pseudoterminal is the only way a shell behaves like a shell | | Renderer | xterm.js in a browser tab served from localhost, with a locked-down sequence policy | you get a competent parser and you still decide what it honours | | Shell integration | A snippet the user installs that emits the OSC 133 prompt markers | the terminal cannot know where a command starts unless the shell tells it | | Storage | SQLite for block metadata, output bodies as files on disk under a retention | the history is the best feature here and also the thing you would least like copied | | Assistant | One request per explicit ask, through a single function that refuses unfiltered text | the model is the only part of this build that leaves the machine | | Redaction | Pattern rules plus a deny list of variable names, with an attack corpus in the suite | a redactor nobody has attacked is decoration | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the pty bridge and the build. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Which shell you will support first** · free - Why: The integration snippet is per shell, and supporting three badly is worse than supporting the one you actually use. - Get it: Pick the shell you run daily and write the snippet for its prompt hooks. - Verify: The snippet emits the markers and block rows appear - [ ] **A throwaway container or virtual machine** · free - Why: Phase 2 involves piping deliberately hostile output through the renderer, and you want that happening somewhere you do not mind breaking. - Get it: Any container runtime, or a virtual machine with a snapshot. - Verify: You can run the hostile fixture inside it and discard the result - [ ] **An API key for a text model** (optional) · pay per request; the ceiling in this build is what keeps it small - Why: The assistant is the only paid and only networked part of the build, and it is optional by design. - Get it: Create a key with your preferred provider and set a spend limit in their console as well. - Verify: One request returns a completion and writes an outbound row - [ ] **A corpus of fabricated credentials** · free - Why: The redactor is only as good as what you have attacked it with, and real credentials must never be the test data. - Get it: Write fake keys, headers, private key blocks and connection strings by hand into the fixtures directory. - Verify: The suite runs them and reports zero unmasked matches ## Quick start ```sh npm i node-pty @xterm/xterm ws ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. - IDE-wide polish and latency - enterprise policy, telemetry, and support - frontier coding model quality - large-context infrastructure If one of those is essential to you, that is the reason to keep paying for Warp, and the README should say so rather than pretend.
# Build brief · Warp The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a terminal with command blocks and an assistant, where nothing leaves the machine unless you selected it and watched it go to replace Warp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A terminal sits between you and everything you have access to, and traffic runs both ways. Decide what you do about each direction before you write a line of it. Outbound: shell integration is not a cosmetic feature. The moment the shell emits the marker sequences, the terminal knows the exact command line you typed, the working directory you typed it in, the exit code, and the entire output block. That block is your keys when you run a command that prints the environment, your production hostnames when you run a deployment tool, the contents of a configuration file you happened to display, and the bearer token in a request you pasted. An assistant with a context window over that is an exfiltration channel with a friendly name. So: nothing reaches a model unless you selected the blocks and saw a preview of the exact bytes, redaction runs inside the one function that performs the request rather than in the interface, and every request writes a row recording the byte count and a hash of what was sent. The terminal is fully usable with the model switched off, and that is the default on first run. Inbound: everything a command prints is untrusted input that your renderer will interpret. xterm's own manual says of the extended window control sequences that each of these can be abused in a script, and that most terminal emulators which implement them restrict only a small part of the repertoire. Its allowWindowOps resource therefore defaults to false. Copy that posture rather than discovering it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with node-pty, bound to the loopback interface only · a real pseudoterminal is the only way a shell behaves like a shell - Renderer: xterm.js in a browser tab served from localhost, with a locked-down sequence policy · you get a competent parser and you still decide what it honours - Shell integration: A snippet the user installs that emits the OSC 133 prompt markers · the terminal cannot know where a command starts unless the shell tells it - Storage: SQLite for block metadata, output bodies as files on disk under a retention · the history is the best feature here and also the thing you would least like copied - Assistant: One request per explicit ask, through a single function that refuses unfiltered text · the model is the only part of this build that leaves the machine - Redaction: Pattern rules plus a deny list of variable names, with an attack corpus in the suite · a redactor nobody has attacked is decoration ### Data model (create this before Phase 1) - blocks(id, session_id, started_at, ended_at, command, cwd, exit_code, output_path, bytes, truncated) · one row per command, boundaries taken from the shell markers - sessions(id, shell, started_at, ended_at, tty, env_fingerprint) · env_fingerprint is a hash, never the variables themselves - rules(id, pattern_kind, pattern, label, enabled) · redaction rules, shipped with defaults and editable by hand - redactions(id, block_id, rule_id, span_start, span_end, label) · what was masked and by which rule, so a false positive is findable later - outbound(id, at, kind, block_ids_json, byte_count, sha256, model_id, cost_minor) · one row per request that left the machine - proposals(id, block_id, command_argv_json, rationale, accepted_at, rejected_at) · a suggested command is a row you can read, never a keystroke pushed into the pty - secrets_flagged(block_id, at, reason) · blocks excluded from the search index and from any selection offered to the model - No table here holds a model answer beside the raw output it read. The raw output stays in its block file and the answer is a proposal that points at it. ### Phase 1 · A real pseudoterminal, and blocks the shell tells you about Build: Spawn a login shell under node-pty, wire it to xterm.js over a websocket, and propagate window resizes to the pty. Ship a shell snippet that emits the OSC 133 markers: A at prompt start, B at prompt end, C before execution and D with the exit code afterwards. Turn those markers into block rows. Without the snippet installed, run as a plain terminal and say so in the interface. Trap: Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. Done when: With the snippet uninstalled the application works as an ordinary terminal and shows a visible notice that block capture is off, tested by launching with a clean profile. With it installed, a block row carries the exact command line, the working directory at execution time and the exit code, checked against a script that exits with code 3. Running a full-screen program such as an editor or a process viewer creates no spurious blocks, tested. Resizing the window resizes the pty, verified by printing the terminal size inside the shell after a resize. Do not build yet: No assistant, no redaction, no history search and no export yet. ### Phase 2 · Program output is hostile input Build: Write down which escape sequences the renderer honours and implement the rest as no-ops. Refuse selection manipulation, title and icon reporting, checksum reporting and property setting. Bracket every paste and neutralise control characters inside pasted text. Cap the bytes stored per block with a visible truncation marker, and ask before sending an unusually large paste. Trap: Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. Trap: A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. Done when: A crafted file containing selection, colour and title query sequences is displayed and produces zero bytes written back to the pty, asserted in a test. A paste containing a newline and an escape character is delivered bracketed with the control characters replaced, tested. Block output is capped at a configured byte count on disk and the truncation is visible in the interface. A paste above a configured size asks for confirmation before it reaches the shell. Do not build yet: No model calls, redaction or history search yet. ### Phase 3 · One door out, with a filter welded to it Build: Write one function that performs the model request. It accepts only text that carries a filter receipt, and it throws otherwise. The filter masks private key blocks, authorisation headers, connection strings, key-shaped tokens and the values of any variable on a deny list, recording each masked span. Every call writes an outbound row with the byte count and a hash of the payload. Trap: The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. Done when: Calling the outbound function with unfiltered text throws, asserted by calling it directly. A corpus of fabricated credentials covering a private key block, an authorisation header, a database connection string and a key-shaped token yields zero matches in the filtered output, tested. Every outbound request writes a row with byte count, hash, model identifier and cost. The preview shows the exact bytes that will be sent and refusing the preview cancels the request. Do not build yet: No assistant behaviour, history search or retention yet. ### Phase 4 · An assistant that proposes and never types Build: Send selected blocks and a question. Render any suggested command as a proposal row showing the fully expanded argument vector, with copy and an explicit run action. Nothing the model returns is ever written into the pty automatically. Classify destructive shapes and require the command to be retyped before those run. Trap: Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. Done when: No code path writes model output into the pty, asserted by a test that the write function is called only from the key and paste handlers. A proposal displays the expanded argument vector and running it requires a separate deliberate action, checked by review. A command matching the destructive classifier requires retyping before it runs, tested. With no model key configured the terminal starts and works, with the assistant panel absent rather than broken. Do not build yet: No search, export, retention or spend ceiling yet. ### Phase 5 · History you own, and a bill that stops Build: Full-text search over commands and output, excluding flagged blocks. Export every block as JSONL with its output files. Delete output bodies after a retention while keeping the rows. Cap the monthly model spend. Generate a report from the outbound table listing every request that left the machine, with dates, byte counts and hashes. Trap: A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. Done when: Search returns matches from commands and output and never returns a flagged block, tested. Export produces JSONL plus output files and reimports into an empty database with identical block counts and hashes, tested. Output bodies older than the retention are deleted and their rows survive with command, exit code and byte count intact, tested. Reaching the monthly ceiling disables the assistant with a message and leaves the terminal working. Do not build yet: Nothing after this. ### Out of scope (and why) - Team workflows and shared history, which is most of what the subscription sells and means holding other people's shell sessions on your server. - An agent that runs commands unattended, because the input it reasons over is program output and program output is written by whatever you just ran. - Recording sessions on remote hosts over SSH, which needs the integration installed there and quietly doubles the blast radius. - A native GPU renderer and the latency work that goes with it, which is genuinely months and is the thing people notice first. ### README must contain - Exactly what the shell integration observes: command line, working directory, exit code and the whole output block - What leaves the machine, where the outbound log lives, and how to read it - That the assistant is off until a key is configured, and how to keep it off - The escape sequence policy and why title, selection and checksum reporting are implemented as no-ops - Where the history and output files live, how long they are kept, and the command that deletes them
# Agent instructions · Warp indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with node-pty, bound to the loopback interface only, xterm.js in a browser tab served from localhost, with a locked-down sequence policy, A snippet the user installs that emits the OSC 133 prompt markers, SQLite for block metadata, output bodies as files on disk under a retention, One request per explicit ask, through a single function that refuses unfiltered text, Pattern rules plus a deny list of variable names, with an attack corpus in the suite. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste.
# Build plan · Warp A block-based terminal whose hard problems are getting command boundaries from the shell instead of guessing at them, treating program output as hostile input to the renderer, and putting a tested redaction boundary in front of the one function that talks to a model. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A real pseudoterminal, and blocks the shell tells you about Commands, working directories and exit codes come from the shell rather than from pattern matching on the screen. ### Steps 1. Spawn a pseudoterminal, not a pipe A shell attached to a pipe disables its prompt, its job control and its colour. Files: `src/pty.ts`, `src/server.ts` ```sh npm i node-pty @xterm/xterm ws ``` 2. Take block boundaries from the OSC 133 markers The shell knows where a command starts and the screen does not. Files: `shell/integration.sh`, `src/blocks.ts`, `tests/blocks.test.ts` 3. Degrade to a plain terminal when the snippet is absent Silently guessing is worse than visibly not knowing. Files: `src/blocks.ts`, `src/ui/Banner.tsx` ### Done when - [ ] Without the snippet it runs as a plain terminal and says so - [ ] Command, working directory and exit code are captured exactly - [ ] A full-screen program creates no spurious blocks - [ ] A window resize reaches the pty ### Watch out - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. ## Phase 2 · Program output is hostile input Displaying a file somebody sent you cannot make the terminal answer back, set your clipboard or report your window title. ### Steps 1. Write down the sequence policy and implement the rest as no-ops A list you can read beats whatever the parser happened to support. Files: `src/sequences.ts`, `docs/sequence-policy.md` 2. Feed the renderer a hostile file in the test suite Assert that nothing travels back up the pty. Files: `tests/hostile-output.test.ts`, `tests/fixtures/hostile.bin` 3. Bracket every paste and filter its control characters The clipboard is somebody else's text arriving at a shell prompt. Files: `src/paste.ts`, `tests/paste.test.ts` ### Done when - [ ] Hostile output produces no response bytes - [ ] Pastes are bracketed and control characters replaced - [ ] Block output is capped with a visible truncation marker - [ ] A large paste asks before it is sent ### Watch out - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. ## Phase 3 · One door out, with a filter welded to it Text cannot reach the network without passing the redactor, and every request that left is recorded. ### Steps 1. Make the request function refuse unfiltered text One door, and the lock is on the door rather than on the corridor. Files: `src/outbound.ts`, `tests/outbound.test.ts` 2. Keep an attack corpus in the repository Fabricated credentials only, and the suite runs them on every change. Files: `tests/fixtures/credentials/`, `tests/redaction.test.ts` 3. Record byte count and hash for every request So the question of what left this machine has an answer. Files: `src/outbound.ts`, `src/db/outbound.ts` ### Done when - [ ] Unfiltered text cannot reach the network - [ ] The credential corpus is fully masked - [ ] Every request writes an outbound row - [ ] The preview shows the exact bytes and can cancel ### Watch out - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. ## Phase 4 · An assistant that proposes and never types A suggested command is inert text until a person deliberately runs it. ### Steps 1. Keep the pty write path reachable from two places only Key handling and paste. Everything else is a proposal. Files: `src/pty.ts`, `tests/no-injection.test.ts` 2. Show the argument vector, not a string A pipe hidden in a suggestion is the whole attack. Files: `src/ui/Proposal.tsx` 3. Make the model optional at startup The default first run has no key and is a good terminal. Files: `src/config.ts`, `README.md` ### Done when - [ ] Model output never reaches the pty - [ ] Proposals show the expanded argument vector - [ ] Destructive commands require retyping - [ ] The terminal runs with no model configured ### Watch out - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ## Not in this build - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. ## After v1, if you want it - Per-directory profiles, so a production checkout can force the assistant off entirely - A second shell integration snippet, once the first one has survived a month of real use - Encrypting the output directory at rest, which is the obvious next thing after the retention policy
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. DATABASE_PATH=./data/terminal.db # Required. Block output bodies on disk. Expired on a schedule, rows retained. OUTPUT_DIR=./data/output # Required. How long output bodies survive. Shorter is the sane default for a laptop. OUTPUT_RETENTION_DAYS=14 # Required. Per-block storage cap. Above it the block is truncated visibly. MAX_BLOCK_BYTES=262144 # Required. Variable names whose values are masked wherever they appear in output. REDACTION_DENY_VARS=AWS_SECRET_ACCESS_KEY,DATABASE_URL,GITHUB_TOKEN # Optional · secret. Absent by default. Without it the assistant panel is not shown. MODEL_API_KEY=sk-...redacted # Optional. Recorded on every outbound row, because answers differ by model. MODEL_ID=a text model identifier # Required. The assistant stops at this figure and the terminal carries on. MONTHLY_COST_CEILING=5.00
# Warp · product brief ## Problem The visible AI terminal loop is buildable, but a credible replacement needs more than the first screen. Warp earns its keep through model, context, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A terminal that gets its command boundaries from the shell, treats program output as hostile, keeps a searchable local history that expires, and can tell you precisely what bytes ever left the machine. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Python 3.12 - Git - OpenAI API key in .env - a disposable test repository ## Explicit non-goals for v1 - Team workflows and shared history. - An agent that runs commands unattended. - Remote session recording over SSH. - A native GPU renderer. - IDE-wide polish and latency - enterprise policy, telemetry, and support - frontier coding model quality - large-context infrastructure ## Success criteria - Hostile output is confirmed to produce no response bytes - The outbound function is confirmed to reject unfiltered text - Model output is confirmed never to reach the pty - Flagged blocks are confirmed absent from the search index - The terminal is confirmed to run with no model key configured
# Build brief · Warp The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a terminal with command blocks and an assistant, where nothing leaves the machine unless you selected it and watched it go to replace Warp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A terminal sits between you and everything you have access to, and traffic runs both ways. Decide what you do about each direction before you write a line of it. Outbound: shell integration is not a cosmetic feature. The moment the shell emits the marker sequences, the terminal knows the exact command line you typed, the working directory you typed it in, the exit code, and the entire output block. That block is your keys when you run a command that prints the environment, your production hostnames when you run a deployment tool, the contents of a configuration file you happened to display, and the bearer token in a request you pasted. An assistant with a context window over that is an exfiltration channel with a friendly name. So: nothing reaches a model unless you selected the blocks and saw a preview of the exact bytes, redaction runs inside the one function that performs the request rather than in the interface, and every request writes a row recording the byte count and a hash of what was sent. The terminal is fully usable with the model switched off, and that is the default on first run. Inbound: everything a command prints is untrusted input that your renderer will interpret. xterm's own manual says of the extended window control sequences that each of these can be abused in a script, and that most terminal emulators which implement them restrict only a small part of the repertoire. Its allowWindowOps resource therefore defaults to false. Copy that posture rather than discovering it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with node-pty, bound to the loopback interface only · a real pseudoterminal is the only way a shell behaves like a shell - Renderer: xterm.js in a browser tab served from localhost, with a locked-down sequence policy · you get a competent parser and you still decide what it honours - Shell integration: A snippet the user installs that emits the OSC 133 prompt markers · the terminal cannot know where a command starts unless the shell tells it - Storage: SQLite for block metadata, output bodies as files on disk under a retention · the history is the best feature here and also the thing you would least like copied - Assistant: One request per explicit ask, through a single function that refuses unfiltered text · the model is the only part of this build that leaves the machine - Redaction: Pattern rules plus a deny list of variable names, with an attack corpus in the suite · a redactor nobody has attacked is decoration ### Data model (create this before Phase 1) - blocks(id, session_id, started_at, ended_at, command, cwd, exit_code, output_path, bytes, truncated) · one row per command, boundaries taken from the shell markers - sessions(id, shell, started_at, ended_at, tty, env_fingerprint) · env_fingerprint is a hash, never the variables themselves - rules(id, pattern_kind, pattern, label, enabled) · redaction rules, shipped with defaults and editable by hand - redactions(id, block_id, rule_id, span_start, span_end, label) · what was masked and by which rule, so a false positive is findable later - outbound(id, at, kind, block_ids_json, byte_count, sha256, model_id, cost_minor) · one row per request that left the machine - proposals(id, block_id, command_argv_json, rationale, accepted_at, rejected_at) · a suggested command is a row you can read, never a keystroke pushed into the pty - secrets_flagged(block_id, at, reason) · blocks excluded from the search index and from any selection offered to the model - No table here holds a model answer beside the raw output it read. The raw output stays in its block file and the answer is a proposal that points at it. ### Phase 1 · A real pseudoterminal, and blocks the shell tells you about Build: Spawn a login shell under node-pty, wire it to xterm.js over a websocket, and propagate window resizes to the pty. Ship a shell snippet that emits the OSC 133 markers: A at prompt start, B at prompt end, C before execution and D with the exit code afterwards. Turn those markers into block rows. Without the snippet installed, run as a plain terminal and say so in the interface. Trap: Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. Done when: With the snippet uninstalled the application works as an ordinary terminal and shows a visible notice that block capture is off, tested by launching with a clean profile. With it installed, a block row carries the exact command line, the working directory at execution time and the exit code, checked against a script that exits with code 3. Running a full-screen program such as an editor or a process viewer creates no spurious blocks, tested. Resizing the window resizes the pty, verified by printing the terminal size inside the shell after a resize. Do not build yet: No assistant, no redaction, no history search and no export yet. ### Phase 2 · Program output is hostile input Build: Write down which escape sequences the renderer honours and implement the rest as no-ops. Refuse selection manipulation, title and icon reporting, checksum reporting and property setting. Bracket every paste and neutralise control characters inside pasted text. Cap the bytes stored per block with a visible truncation marker, and ask before sending an unusually large paste. Trap: Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. Trap: A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. Done when: A crafted file containing selection, colour and title query sequences is displayed and produces zero bytes written back to the pty, asserted in a test. A paste containing a newline and an escape character is delivered bracketed with the control characters replaced, tested. Block output is capped at a configured byte count on disk and the truncation is visible in the interface. A paste above a configured size asks for confirmation before it reaches the shell. Do not build yet: No model calls, redaction or history search yet. ### Phase 3 · One door out, with a filter welded to it Build: Write one function that performs the model request. It accepts only text that carries a filter receipt, and it throws otherwise. The filter masks private key blocks, authorisation headers, connection strings, key-shaped tokens and the values of any variable on a deny list, recording each masked span. Every call writes an outbound row with the byte count and a hash of the payload. Trap: The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. Done when: Calling the outbound function with unfiltered text throws, asserted by calling it directly. A corpus of fabricated credentials covering a private key block, an authorisation header, a database connection string and a key-shaped token yields zero matches in the filtered output, tested. Every outbound request writes a row with byte count, hash, model identifier and cost. The preview shows the exact bytes that will be sent and refusing the preview cancels the request. Do not build yet: No assistant behaviour, history search or retention yet. ### Phase 4 · An assistant that proposes and never types Build: Send selected blocks and a question. Render any suggested command as a proposal row showing the fully expanded argument vector, with copy and an explicit run action. Nothing the model returns is ever written into the pty automatically. Classify destructive shapes and require the command to be retyped before those run. Trap: Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. Done when: No code path writes model output into the pty, asserted by a test that the write function is called only from the key and paste handlers. A proposal displays the expanded argument vector and running it requires a separate deliberate action, checked by review. A command matching the destructive classifier requires retyping before it runs, tested. With no model key configured the terminal starts and works, with the assistant panel absent rather than broken. Do not build yet: No search, export, retention or spend ceiling yet. ### Phase 5 · History you own, and a bill that stops Build: Full-text search over commands and output, excluding flagged blocks. Export every block as JSONL with its output files. Delete output bodies after a retention while keeping the rows. Cap the monthly model spend. Generate a report from the outbound table listing every request that left the machine, with dates, byte counts and hashes. Trap: A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page. Done when: Search returns matches from commands and output and never returns a flagged block, tested. Export produces JSONL plus output files and reimports into an empty database with identical block counts and hashes, tested. Output bodies older than the retention are deleted and their rows survive with command, exit code and byte count intact, tested. Reaching the monthly ceiling disables the assistant with a message and leaves the terminal working. Do not build yet: Nothing after this. ### Out of scope (and why) - Team workflows and shared history, which is most of what the subscription sells and means holding other people's shell sessions on your server. - An agent that runs commands unattended, because the input it reasons over is program output and program output is written by whatever you just ran. - Recording sessions on remote hosts over SSH, which needs the integration installed there and quietly doubles the blast radius. - A native GPU renderer and the latency work that goes with it, which is genuinely months and is the thing people notice first. ### README must contain - Exactly what the shell integration observes: command line, working directory, exit code and the whole output block - What leaves the machine, where the outbound log lives, and how to read it - That the assistant is off until a key is configured, and how to keep it off - The escape sequence policy and why title, selection and checksum reporting are implemented as no-ops - Where the history and output files live, how long they are kept, and the command that deletes them
# Architecture · Warp ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with node-pty, bound to the loopback interface only | a real pseudoterminal is the only way a shell behaves like a shell | | Renderer | xterm.js in a browser tab served from localhost, with a locked-down sequence policy | you get a competent parser and you still decide what it honours | | Shell integration | A snippet the user installs that emits the OSC 133 prompt markers | the terminal cannot know where a command starts unless the shell tells it | | Storage | SQLite for block metadata, output bodies as files on disk under a retention | the history is the best feature here and also the thing you would least like copied | | Assistant | One request per explicit ask, through a single function that refuses unfiltered text | the model is the only part of this build that leaves the machine | | Redaction | Pattern rules plus a deny list of variable names, with an attack corpus in the suite | a redactor nobody has attacked is decoration | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Pty bridge | Process spawn, resize, and the only write path to the shell. | Any pty binding, provided the write path stays reachable from two call sites. | | Blocks | Marker parsing and block rows. | Any marker scheme the shell can emit. Not swappable for screen scraping. | | Sequences | What the renderer honours and what it ignores. | Any renderer, provided the reporting sequences stay no-ops. | | Outbound | Redaction, the single request function and the log. | Any provider, provided the filter stays inside the request function. | | Archive | Search, export, retention and the outbound report. | Any index, provided flagged blocks are excluded from it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. - `OUTPUT_DIR` · required · Block output bodies on disk. Expired on a schedule, rows retained. - `OUTPUT_RETENTION_DAYS` · required · How long output bodies survive. Shorter is the sane default for a laptop. - `MAX_BLOCK_BYTES` · required · Per-block storage cap. Above it the block is truncated visibly. - `REDACTION_DENY_VARS` · required · Variable names whose values are masked wherever they appear in output. - `MODEL_API_KEY` · optional, secret · Absent by default. Without it the assistant panel is not shown. - `MODEL_ID` · optional · Recorded on every outbound row, because answers differ by model. - `MONTHLY_COST_CEILING` · required · The assistant stops at this figure and the terminal carries on. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Warp product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with node-pty, bound to the loopback interface only, xterm.js in a browser tab served from localhost, with a locked-down sequence policy, A snippet the user installs that emits the OSC 133 prompt markers, SQLite for block metadata, output bodies as files on disk under a retention, One request per explicit ask, through a single function that refuses unfiltered text, Pattern rules plus a deny list of variable names, with an attack corpus in the suite. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. - A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page.
# Delivery milestones · Warp Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A real pseudoterminal, and blocks the shell tells you about Commands, working directories and exit codes come from the shell rather than from pattern matching on the screen. ### Steps 1. Spawn a pseudoterminal, not a pipe A shell attached to a pipe disables its prompt, its job control and its colour. Files: `src/pty.ts`, `src/server.ts` ```sh npm i node-pty @xterm/xterm ws ``` 2. Take block boundaries from the OSC 133 markers The shell knows where a command starts and the screen does not. Files: `shell/integration.sh`, `src/blocks.ts`, `tests/blocks.test.ts` 3. Degrade to a plain terminal when the snippet is absent Silently guessing is worse than visibly not knowing. Files: `src/blocks.ts`, `src/ui/Banner.tsx` ### Done when - [ ] Without the snippet it runs as a plain terminal and says so - [ ] Command, working directory and exit code are captured exactly - [ ] A full-screen program creates no spurious blocks - [ ] A window resize reaches the pty ### Watch out - Working out where a command started by scanning the screen for something that looks like a prompt breaks the first time somebody changes their prompt string, opens a full-screen editor, or prints a line containing a dollar sign, and from then on output is attributed to the wrong command in a store you will later hand to a model. Take the boundaries from the shell: the OSC 133 sequence marks prompt start, prompt end, pre-execution and the exit code, and if the snippet is not installed the terminal says block capture is off instead of guessing. ## M2 · Program output is hostile input Displaying a file somebody sent you cannot make the terminal answer back, set your clipboard or report your window title. ### Steps 1. Write down the sequence policy and implement the rest as no-ops A list you can read beats whatever the parser happened to support. Files: `src/sequences.ts`, `docs/sequence-policy.md` 2. Feed the renderer a hostile file in the test suite Assert that nothing travels back up the pty. Files: `tests/hostile-output.test.ts`, `tests/fixtures/hostile.bin` 3. Bracket every paste and filter its control characters The clipboard is somebody else's text arriving at a shell prompt. Files: `src/paste.ts`, `tests/paste.test.ts` ### Done when - [ ] Hostile output produces no response bytes - [ ] Pastes are bracketed and control characters replaced - [ ] Block output is capped with a visible truncation marker - [ ] A large paste asks before it is sent ### Watch out - Displaying a downloaded file is enough for that file to set your window title, ask the terminal to report it back as if you had typed it, or read your selection out as a base64 string, and xterm ships with allowWindowOps false precisely because of this, its disallowed list naming GetSelection, SetSelection, GetWinTitle, GetIconTitle, GetChecksum and SetXprop. Implement those as no-ops and write a test that pipes a crafted file through the renderer and asserts that not one byte travelled back up the pty. - A pasted block of text containing a newline runs as soon as it lands unless paste is bracketed, and a pasted escape character can put the shell into a mode the user did not ask for, which is why xterm converts backspace, delete, enquiry, end-of-transmission, escape, null and the stty specials to spaces on paste by default. Bracket the paste and filter the same class of characters rather than trusting the clipboard. ## M3 · One door out, with a filter welded to it Text cannot reach the network without passing the redactor, and every request that left is recorded. ### Steps 1. Make the request function refuse unfiltered text One door, and the lock is on the door rather than on the corridor. Files: `src/outbound.ts`, `tests/outbound.test.ts` 2. Keep an attack corpus in the repository Fabricated credentials only, and the suite runs them on every change. Files: `tests/fixtures/credentials/`, `tests/redaction.test.ts` 3. Record byte count and hash for every request So the question of what left this machine has an answer. Files: `src/outbound.ts`, `src/db/outbound.ts` ### Done when - [ ] Unfiltered text cannot reach the network - [ ] The credential corpus is fully masked - [ ] Every request writes an outbound row - [ ] The preview shows the exact bytes and can cancel ### Watch out - The usual mistake is redacting for display and sending the original, because the highlight and the request get written a week apart by somebody who has forgotten the other exists. Put the filter inside the request function, have it refuse text without a receipt, and prove it by asserting in a test that a block containing a fabricated key never appears anywhere in the recorded request body. ## M4 · An assistant that proposes and never types A suggested command is inert text until a person deliberately runs it. ### Steps 1. Keep the pty write path reachable from two places only Key handling and paste. Everything else is a proposal. Files: `src/pty.ts`, `tests/no-injection.test.ts` 2. Show the argument vector, not a string A pipe hidden in a suggestion is the whole attack. Files: `src/ui/Proposal.tsx` 3. Make the model optional at startup The default first run has no key and is a good terminal. Files: `src/config.ts`, `README.md` ### Done when - [ ] Model output never reaches the pty - [ ] Proposals show the expanded argument vector - [ ] Destructive commands require retyping - [ ] The terminal runs with no model configured ### Watch out - Pushing a suggested command into the input line so the user can edit it turns a poisoned line in a build log into a command sitting at your prompt, and the model read that log precisely because you asked it to explain the failure. Render proposals as inert rows, show the argument vector rather than a string with pipes in it, and keep a test asserting that the pty write path is reachable only from key handling and paste. ## M5 · History you own, and a bill that stops (production only) The archive is searchable, exportable, expiring, and the spend has a ceiling. ### Steps 1. Exclude flagged blocks from the index A secret you masked in transit is still on disk unless you say otherwise. Files: `src/search.ts`, `tests/search-exclusion.test.ts` 2. Expire output bodies, keep the rows The command history stays useful long after the output stops being. Files: `src/retention.ts` 3. Generate the outbound report from the table Written from data rather than from memory of what you built. Files: `src/reports/outbound.ts`, `README.md` ### Done when - [ ] Flagged blocks never appear in search results - [ ] Export and reimport round-trips blocks and hashes - [ ] Output bodies expire and rows survive - [ ] The spend ceiling stops the assistant, not the terminal ### Watch out - A searchable index of every command you have run is the best thing in this build and the worst thing to leave in plaintext on a laptop that travels, and redaction in transit does nothing about redaction at rest. Exclude flagged blocks from the index entirely, expire output bodies on a schedule, and put the what-left-this-machine report on the first screen of the README rather than in a settings page.
# Operations · Warp ## Backup The database holds the command history and the outbound log; the output directory holds bodies that expire anyway. Back up the database, and decide consciously whether you want a backup of your shell history existing at all. ## Restore Restore the database, confirm block counts and that export hashes match, then decide whether to restore output bodies or let them stay gone. A restore that resurrects expired output has quietly undone the retention policy. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Monthly model spend against the ceiling, the count of outbound rows per week, the rate of redaction matches, and whether the hostile-output test still passes after any renderer upgrade. A jump in outbound rows usually means a new code path found the request function. ## Incident checklist The incident here is a secret that left. The outbound table gives you the date, the byte count and the hash of every payload, and the redactions table tells you which rules fired on the blocks involved, which is the difference between rotating one credential and rotating all of them. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Hostile output is confirmed to produce no response bytes - [ ] The outbound function is confirmed to reject unfiltered text - [ ] Model output is confirmed never to reach the pty - [ ] Flagged blocks are confirmed absent from the search index - [ ] The terminal is confirmed to run with no model key configured ## Launch constraint Do not market omitted Warp capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Blocks, sessions, redactions and the outbound log. Back this up or delete it deliberately. DATABASE_PATH=./data/terminal.db # Required. Block output bodies on disk. Expired on a schedule, rows retained. OUTPUT_DIR=./data/output # Required. How long output bodies survive. Shorter is the sane default for a laptop. OUTPUT_RETENTION_DAYS=14 # Required. Per-block storage cap. Above it the block is truncated visibly. MAX_BLOCK_BYTES=262144 # Required. Variable names whose values are masked wherever they appear in output. REDACTION_DENY_VARS=AWS_SECRET_ACCESS_KEY,DATABASE_URL,GITHUB_TOKEN # Optional · secret. Absent by default. Without it the assistant panel is not shown. MODEL_API_KEY=sk-...redacted # Optional. Recorded on every outbound row, because answers differ by model. MODEL_ID=a text model identifier # Required. The assistant stops at this figure and the terminal carries on. MONTHLY_COST_CEILING=5.00
$ choose a build depth, inspect the files, then open the complete pack in your agent
Warp: Developers pay for reliable context assembly, fast models, editor integration, evaluations, and safe handling of complex repositories.
xIDE-wide polish and latency
xenterprise policy, telemetry, and support
xfrontier coding model quality
xlarge-context infrastructure
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Warp pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 0 bundled AI credits; core terminal and bring-your-own-inference access; limited cloud/storage features. |
| build | $20/user | $18/user | 1,500 AI credits/month, described as $20 of included usage. |
| max | $200/user | $180/user | 18,000 AI credits/month. |
| business | $50/user | $45/user | Up to 25 seats; 1,500 AI credits/user/month. |
| enterprise | custom | — | Unlimited seats; custom shared credit pool, controls and support. |
free tier0 bundled AI credits; core terminal and BYO inference remain available with limited cloud/storage features
billingmonthly or annual for Build, Max and Business; Enterprise custom
hidden costsBundled credits expire each billing cycle. Reload credits roll over but expire after 12 months; the default monthly reload spend limit is $200 unless changed. A single agent run can consume AI, compute and platform-credit buckets separately, and BYO models do not eliminate every platform charge.
verified 2026-08-14 · source ↗
Vibecode Warp
Kinda. The core of Warp is buildable in a weekend with the prompt on this page, but there are real gaps: IDE-wide polish and latency, enterprise policy, telemetry, and support. Read the honest list above before committing.
How much does Warp cost?
Warp costs about $18/month (Pro, checked 2026-07-31), which is $216 per year.
What do I lose by replacing Warp?
Honestly: IDE-wide polish and latency; enterprise policy, telemetry, and support; frontier coding model quality; large-context infrastructure. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Warp?
Yes: cmux (A native Mac terminal for many agents; no cloud memory and no Windows version.) Wave Terminal (A modern block terminal with workspaces and AI, minus the account requirement.) The prompt is for when you want it exactly your way.