---
type: Reference
title: "PMOS Terminal Workspace"
timestamp: 2026-07-30
---

# PMOS Terminal Workspace

The local companion that runs the PM's agent sessions as **real terminals**, with the PMOS
workflow (library, gates, needs-you) around them. Built to D62 (as ratified + twice amended):
a **persistent, localhost-only session server** owning real PTYs, with a **GUI client** attached
over WebSocket. The native TUI is a deferred later client (D62 amendment 2).

## Why it can't lose your harness
Each session spawns your agent CLI (Claude Code / Codex / a shell) as a real PTY; xterm.js is a
true terminal emulator on the other end. The CLI runs **untouched** — its own login (Claude
subscription included), slash commands, keybindings. The workspace is the terminal, not a wrapper.

## Everything survives a session
The server holds PTYs in memory and mirrors each session's scrollback + metadata to the runtime
state directory — **`~/Library/Application Support/pmos` on macOS**, `$XDG_STATE_HOME/pmos` or
`~/.local/state/pmos` elsewhere, `PMOS_STATE_DIR` overriding both. It is deliberately OUTSIDE the
app directory: sessions, the workspace registry, layouts, scratchpads, schedules and your authored
skills used to live at `workspace/state/`, inside the directory the test suite runs from, and were
twice destroyed by a test. An existing in-repo `state/` is moved there once, on first run; two
populated directories are never merged (you get told, and nothing moves).
Close/reload the client → re-attach replays scrollback and the run continues (detach, not kill).
Restart the server → prior sessions reload as `exited` with full history and one-click respawn.

## Run it — just type `pmos`
One-time setup (installs the `pmos` command on your machine):
```bash
cd workspace
npm install -g .
```
Then, from any terminal, any folder:
```bash
pmos                 # starts the workspace (installs deps on first run) + opens the browser
```
That's it. The server keeps running after you close the terminal — sessions survive (R7).
```bash
pmos status          # is it running? which sessions?
pmos stop            # shut it down (scrollback is preserved; sessions respawn next time)
pmos doctor          # diagnose the environment (node-pty arch, PTY spawn test, ports)
```
**The port is found, not assumed.** A running server records the port it serves, so `pmos` in a
second repo joins the workspace you already have open — whatever port it ended up on, and however it
was started. If another program holds the default port (7433), `pmos` starts on the next free one and
says so; `PMOS_WS_PORT=7500 pmos` pins it by hand. `pmos stop` shuts down every workspace server it
finds, which is how you collapse two back into one.

<details><summary>Without the global install (running from the repo)</summary>

```bash
cd workspace
npm install          # node-pty (native), ws, @xterm/*
npm start            # http://127.0.0.1:7433  (localhost only — never public, D62)
npm test             # end-to-end checks: PTY round-trip, restart-persistence, gates, launcher
npm run test:ui      # browser checks: terminal geometry, reader scroll, markdown (needs Chrome)
```
</details>

## Layout — four panels (PM-directed design, built by `workspace-four-panel`)
The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top
bar and a 26px status bar, on the oklch token layer, in both themes:

1. **Repositories** — every repo you've added, with its kind (`⎇` github / `▤` local) and a
   `github|local · N initiatives` sub-line. `＋` adds one by picking a folder (typing a path still
   works; where no chooser exists, an in-app folder browser stands in). The **settings & tools**
   drawer holds the secondary modes in two groups: *this repository* (`agents` · `memory` ·
   `knowledge`) and *all repositories* (`focus` · `skills` · `connections` · `scheduled`). There is
   deliberately no `workflow` entry — workflows are decided by the agent at runtime, not configured.
2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
   box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
   initiatives are filed honestly and shown **rejected** under D59, never hidden.
   A **`NOW · working on`** group is pinned above the objectives when — and only when — there is
   something to say: every initiative with a live session, a registered working tree on its
   `initiative/<id>` branch, or a tab open in this window, ordered so whatever is **waiting on you**
   is the first row on the board. The three signals are ranked, never merged, and each row's tooltip
   names which one put it there. On a row with a live session the leading `#` becomes that session's
   own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the vocabulary
   `lib/session-state.js` uses everywhere else, with every session named individually on hover. The
   glyphs differ in **shape**, so the signal survives greyscale.
   **A session records which initiative it is for**, so `＋ session` on an initiative is attributed
   to it whatever branch you are on — and that survives a server restart. Where nothing was recorded
   the `initiative/<id>` branch still answers, and the row says when the attribution is only a branch
   reading. A session PMOS cannot place is never guessed at **and never hidden**: it is listed under
   *not on an initiative*, and `＋ bind` records what it is for as your own declaration.
3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the
   **sessions** running on it (each with the same word the sidebar uses — click one to go to its tab
   if it is open, or to open a tab **attached** to the running session; nothing here ever starts an
   agent), the derived footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
   are derived from the workflow ladder unless the record authors them), a **links** section for
   references that are not repo files, and **artifacts** with an external destination and the sync
   panel. A record that fails to read renders **red with the parse error** — never a blank panel.
4. **Tabs** — scoped per repository, two-line labels naming the initiative, three kinds: **term** (a
   real PTY), **doc** (view/raw/edit/diff, save/revert, dirty flag), and **task** (the task spec).
   `＋ session`, close, split.

`⌘K`/`Ctrl-K` focuses search; `Esc` closes the active tab. The panel state you choose survives a
reload.

- **A phone surface.** The same four panel modules render on a phone — one shell, one renderer, no
  phone-shaped copy of a list. `lib/mobile-nav.js` owns the destinations and the stack; the live
  terminal host is never re-parented, so a session you navigate away from keeps running.
- **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or
  exited, and it survives a server restart.
- **Documents read like documents** — the identity block becomes a card, tables are tables,
  nested/ordered/task lists survive, and a relative `.md` link opens that document in the reader.
- **Preview more than markdown** — `.skill` files render as markdown; text/config files open as
  escaped source; `.html` opens as source with an opt-in **sandboxed** render (scripts + network
  denied); markdown renders **repo-relative images inline**. A remote `![](https://…)` image is
  never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).

**Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the
server unable to read any repository — that is how the design comparison is run
(`npm run test:parity`), and it is never the default.

## Artifact links are declarations, not integrations
PMOS makes no outbound request and holds no credential. Linking an artifact to an external
destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to
`pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector
configured its summary line says exactly that: *no connector configured — nothing was read, and
nothing will be written*, with the approval buttons disabled. A difference summary it never read
would be the watermelon this repo has a word for.

## The gates are real, never automatic (R5)
`approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and
clear the matching needs entry — only ever on your click.

**The record lands in the repo that earned it.** Each workspace resolves its own state dir —
`pmos/state/` (the kit's layout, D54), then `state/`, then `.pmos/`; an embedded PMOS-self checkout
with none of those yet uses the install's own `workspace/state/`. A PMOS-shaped repo with no state
dir yet gets the kit layout on first write. That path matters: `scripts/metrics.py` reads
`pmos/state/acceptances.jsonl` for the north star and κ, so an acceptance recorded anywhere else is
a green click over an empty record. A plain (non-PMOS) repo has no layout to invent — the gate
records into `workspace/state/` and the response says `fallback: true` rather than guessing.

This is D64's **system of record** and it did **not** follow the runtime state out to the per-user
directory above. A record belongs to the repository that earned it; only the churny, machine-written
runtime moved.

Records carry the kit's `accepted` field (`accept` → true, `return` → false) so file-mode metrics
count them. `pm_label` is **never** derived from a click — κ labels are the PM's own word (D40).

## Your own runs are searchable (the history lens)
Every session's scrollback is already mirrored to disk, which makes it a record of what earlier
runs actually **did** — the thing structured run rows don't carry. `GET /api/search?q=…&ws=…`
searches it, and an agent working in a pane can ask the question itself. (The four-panel rebuild
has not yet re-surfaced this as a UI lens — the endpoint is live, the front-end entry point is not.)

Two guarantees. It is scoped to **one repo** — a workspace never searches another's history
(R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less.
And every returned line is **scrubbed** of ANSI and of anything credential-shaped (bearer
tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the agent printed,
so D08's never-log-headers discipline applies to what this hands back. Matching runs against
the *scrubbed* text, so the endpoint cannot be used to grep a secret out.

## `search everything…` searches the folder (the file lens)
The search box shipped matching three derived lists — initiative slugs, paths **declared on** an
initiative record, and session names — so a file PMOS had never been told about could not be found
by typing its exact name, and the miss printed *"nothing matches — a successful read of nothing"*
after a search that had never opened the folder.

`GET /api/search/files?q=…&ws=…&limit=…` is the folder corpus: every file under the workspace root,
matched by **path** and by **content**, with the matching line and its number. Clicking a hit opens
it in the ordinary document tab. It is a **different corpus** from `/api/search` above — that one is
scrollback, this one is disk — and they are deliberately separate endpoints, because their hits
share no field and one payload would have to lie about what it looked at.

Three guarantees. The walk uses the **folder lens's own rules** (`node_modules`, `dist`, `.git` and
the rest skipped, dotted names excluded), so search and the file tree can never disagree about what
is in the folder. There is **no path parameter** — the query is a needle, not a location — and the
walk starts at the root and never follows a symlink out of it, so nothing becomes reachable that
`/api/doc` would refuse; `openable` on each hit reports whether the reader can actually show it.
And it is **bounded, out loud**: `truncated` means files were never looked at, `content_truncated`
means every file was named but not every body was read, `scanned`/`content_scanned` give the counts.
Two caps, two flags — merged into one, the serious flag would light on any repository with a lock
file in it, which is the same as retiring it.

## Branches: a new one doesn't move the others

Starting a session, the branch control is **optional** — the default is "stay on current" and it
performs no git operation at all. When you do pick one:

- **`+ new branch…`** can give that branch its **own working tree**, at
  `../<repo>-worktrees/<branch>` (a sibling of the repo — nothing appears inside it, nothing for
  `.gitignore` to chase), registered as its own workspace. Every session already running stays
  exactly where it was. The folder is named in the form before you commit to it, and again in the
  footer once it exists.
- **That's the default only when there are sessions to protect.** Alone in a workspace a plain
  checkout disturbs nobody, so no folder is made. The checkbox is yours to flip either way.
- **Unchecking "give it its own folder"** is the shared-tree checkout: this repo moves, and so does
  every session in it. The hint counts them, and only warns when something really would move.
- A branch can live in **one** working tree at a time. One already open elsewhere is labelled with
  its location in the picker, rather than failing later.

**⑂ worktrees** in the sidebar lists every working tree the repo has, and removes the ones that are
safe to remove. A removal is refused — with the reason — when the tree has sessions running in it,
uncommitted changes, or commits that no other branch, remote or tag holds. Removing one reclaims
the folder and **keeps the branch**; nothing is ever removed automatically. Git history is shared
between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
repo.

## Several sessions in one tree: you are told, never stopped

Sessions in one working tree share **one branch, one index and one set of files**. Starting a
session in a tree that already has live ones now says so in the pane — naming them, and naming the
way out (⑂ a new branch in its own folder). It fires on the plain `＋ session` path too, where no
git operation runs at all: the server already knew, and used to say nothing.

**`＋ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
act on, and the answer you usually want is not "start another" at all — it is "which of those is the
one I was already working in?". So clicking `＋ session` into an occupied tree opens a chooser
first, listing every session in that tree with:

- **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
  `quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
- **what ending it would cost** — one sentence per session, worded from the evidence. Only
  `needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
  `maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
  discards a running turn is worse than no reading at all;
- **`open it`** — attach a tab to that session and start nothing.

`end it` is there too, and is the only destructive control on the surface: two clicks, the first
arming it. `＋ start another session here anyway` is always one click away — informing you is never
obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
back tomorrow answers the same question the chooser did.

**The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
`~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
warned about at all: those are the sessions isolation made safe. A folder with no git has no tree
and says nothing, because there is no index to collide over.

**The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the
others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it;
`POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a
timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is
promoted, because which pane should be driving is your intention and not something to derive.

Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and
the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
(The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)

A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
counts, which matters for a product repo added by path. A folder with **no git** simply says so and
starts the session; nothing about git can block work in a repo that doesn't use it.

## Boundaries (honest v1 seams)
- **Server binds 127.0.0.1** — the D62 hosted-PTY-bridge rejection stands; a tailnet/phone client
  attaches to the same local server, never a public endpoint.
- **needs-you feed** — an explicit `needs.json` (written by the harness/MCP) is authoritative for
  the entries it names; everything else is **derived** from the repo's own append-only state, so a
  repo nobody has seeded still shows its real work. Derived: an eval with no acceptance for its run
  (the Acceptance Gate is open, D12), and a declared outcome past its horizon with no disposition
  (the D57 check is due — a *missing* horizon counts as due, mirroring `metrics.py`). Derivation
  only reports: an overdue outcome carries **no** close button, because disposal is
  `record_outcome_check` with evidence, and auto-disposal is prohibited (D57).
- File reads are **path-guarded** to the repo. The reader serves previewable **text kinds**
  (markdown incl. `.skill`, text/config, HTML source) and streams repo-relative **images** through
  a MIME-allowlisted `/api/asset`; binaries and out-of-root/cross-workspace paths are refused.
  Remote images are never fetched (no beacon). Request headers are never logged (D08).

Screenshots: `docs/screenshot-dark.png`, `docs/screenshot-light.png`, `docs/screenshot-doc.png`.
