# Workspace Server CLI (`ws.mjs`)

Manages the workspace dev server (`server.mjs`) as a background process. Cross-platform (macOS, Linux, Windows), zero npm dependencies.

For installing the server as an always-on supervised daemon (launchd / systemd), see [`DAEMON.md`](./DAEMON.md).

## Usage

```bash
node ws.mjs start [--port N] [--no-open]   # Start server as background daemon
node ws.mjs stop                            # Stop the running server
node ws.mjs restart [--port N] [--no-open]  # Stop then start
node ws.mjs status                          # Show server status (PID, port, uptime, health)
node ws.mjs open                            # Open browser to server URL
```

## Port resolution

The port is resolved in this order (last wins):

1. Default: `4200`
2. `operator/config/preferences.json` key `workspacePort`
3. `--port` CLI flag

## Files

| File | Purpose |
|---|---|
| `.ws.pid` | PID + port tracking (repo root). JSON: `{ "pid": N, "port": N }` |
| `.ws.log` | Server stdout/stderr (repo root). Overwritten on each `start`. |

Both files are gitignored artifacts. They live in the repo root for easy discovery.

## Commands

### `start`

1. Checks if the server is already running (PID file + process check + port check). If so, prints the URL and exits.
2. Recovers from bad states (see table below) or reports them with a one-line fix.
3. Spawns `server.mjs` detached with `--no-timeout` (background servers should not auto-shutdown) and `--no-open` (the CLI handles browser opening).
4. Writes `.ws.pid`.
5. Waits up to 3 seconds for the port to become available, then prints the URL.
6. Opens the browser unless `--no-open` is passed.

#### Bad-state matrix (on `start`)

| PID file | Port | Behaviour |
|---|---|---|
| exists, PID alive, port up | — | Already running — print URL, exit 0. |
| exists, PID alive, port down | — | Stale process. Kill it, clean up, then spawn. |
| exists, PID dead, port free | — | Clean up pidfile, spawn fresh. |
| exists, PID dead, port busy | — | Unverifiable port holder. Print diagnostic with the holder's PID/command and a recovery one-liner, exit 1. Never kills a process we cannot prove we started. |
| missing, port free | — | Spawn fresh. |
| missing, port busy | — | Print diagnostic with holder PID, exit 1. |

On platforms where `lsof` (macOS/Linux) or `netstat` (Windows) is unavailable the holder lookup is skipped and the diagnostic falls back to suggesting manual investigation.

### `stop`

1. Reads `.ws.pid`. If missing or process dead, prints "not running" and exits 0.
2. Sends SIGTERM (or `taskkill` on Windows). Waits up to 5 seconds.
3. If still alive, sends SIGKILL (or `taskkill /F`).
4. Cleans up `.ws.pid`.

### `restart`

When a supervisor (launchd / systemd) is installed, asks the supervisor to bounce the service. Unit names are derived from your `paName` in `operator/config/preferences.json` (with the device-local overlay applied), following the scheduler's `com.{agent}.{id}` convention with `id = workspace`:

- **launchd:** `launchctl kickstart -k gui/$UID/com.<paName>.workspace` — e.g. `com.alex.workspace` for a solo install where `paName: alex`.
- **systemd:** `systemctl --user restart <paName>-workspace.service` — e.g. `alex-workspace.service`.

When `paName` is unset or unresolvable the fallback is `dave`, matching the pre-0.1.44 label. A host that installed the daemon under 0.1.43 or earlier keeps responding to `ws.mjs` / `pa workspace` verbs against the legacy `com.dave.workspace` / `dave-workspace.service` unit until you run `install-daemon install --migrate` to rename in place.

This is the **safe way to pick up an in-place version change** (e.g. after `pnpm update`). `stop` then `start` would race the supervisor's KeepAlive / Restart=always and fail with "Port already in use".

Without a supervisor, falls back to `stop` then `start`.

### `status`

Prints supervisor-aware status:

- **Without supervisor** — running/stopped, PID, port, uptime (from PID file mtime), URL, HTTP health.
- **With supervisor** — supervisor kind (launchd / systemd), unit/plist path, supervisor state (`running` / `loaded` / `inactive`), supervised PID, port, port listener PID (via `lsof` / `ss` / `netstat`), URL, HTTP health.

The supervisor-aware path bypasses the pidfile entirely — a launchd / systemd-managed process is invisible to the legacy pidfile check.

## Supervisor awareness

If a launchd plist at `~/Library/LaunchAgents/com.<paName>.workspace.plist` (macOS) or a systemd unit at `~/.config/systemd/user/<paName>-workspace.service` (Linux) is present, ws.mjs switches into supervisor-aware mode. Detection also picks up the legacy `com.dave.workspace` / `dave-workspace.service` file as a fallback when the paName-derived one is absent, so a colleague install that shipped under 0.1.43 keeps working after upgrade:

| Verb | Supervisor present | Behaviour |
|---|---|---|
| `start` | yes, running | "Already running (supervised by X, PID N)" — exit 0. Does NOT spawn a detached process. |
| `start` | yes, not running | Refuses to spawn; tells the operator how to bring the service up via the supervisor. |
| `stop` | yes | Refuses with a helpful message — `kill <pid>` triggers KeepAlive instant respawn. Exit 1. |
| `restart` | yes | Delegates to `launchctl kickstart -k` / `systemctl --user restart`. |
| `status` | yes | Reports supervisor state + port-listener PID, ignoring the pidfile. |
| `open` | yes | Resolves the port via prefs and opens the URL; no pidfile lookup. |

Detection is purely file-existence: a unit file alone activates the supervisor path. Whether the unit is loaded is reported as `state`. Cross-platform parity is guarded by tests — a launchd plist on Linux (or a systemd unit on macOS) is ignored.

**Test-only env var:** `WORKSPACE_SUPERVISOR_PLATFORM_OVERRIDE` forces `ws.mjs` to treat the host as the named platform (`darwin` / `linux` / `win32`) when probing for a supervisor. This is how the e2e suite exercises both supervisor branches from any host — launchd behaviour can be asserted on Linux CI and systemd behaviour on macOS dev boxes. Unset in production; honoured only by `detectSupervisor`'s existing dependency-injection contract.

### `open`

Opens the server URL in the default browser. Errors if the server is not running.

## Extension mechanism

Operator forks can layer pages and API routes over the core app without forking `server.mjs`.

### Static page overlay

Place HTML files in `operator/extensions/workspace/`. The server checks that directory before `core/tools/workspace/app/`, so any same-named file there takes precedence. No config required — the directory's presence activates the overlay.

```
operator/extensions/workspace/home.html   → overrides core home.html
operator/extensions/workspace/mypage.html → served at /mypage.html
```

### Additional API routes

If `operator/extensions/workspace/api.mjs` exists, the server imports it at startup and calls its `register(addRoute)` export. Use `addRoute(method, pathname, handler)` to register routes. Routes registered here shadow same-method+pathname core routes (logged at startup).

```js
// operator/extensions/workspace/api.mjs
export function register(addRoute) {
  addRoute('GET', '/__api/my-data', (req, res, url, repoRoot) => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ ok: true }));
  });
}
```

### Nav registration

The main sidebar is driven by a declarative manifest rather than hard-coded markup. This lets extensions surface new pages in the nav without shadowing `index.html`.

**Core manifest:** `core/tools/workspace/app/nav.json` defines the built-in entries.

**Extension manifest (optional):** `operator/extensions/workspace/nav.json`. Same shape, merged after core. Missing file is not an error.

**Team fragments (optional):** alongside `nav.json`, both dirs may carry `nav.<team>.json` files (e.g. `nav.hq.json`, `nav.wsd.json`). These are composed together, base `nav.json` first then team fragments alphabetically. They exist because the extension dir is materialised from multiple library repos merged by file path — a single shared `nav.json` collides (highest-precedence repo wins the whole file, dropping the other teams' entries), whereas distinctly-named fragments don't collide, so every team's entries materialise side by side. Which of them a device *displays* is then decided by team scoping (below).

**Shape:**

```json
[
  {
    "id": "mypage",
    "label": "My Page",
    "href": "/mypage.html",
    "section": "Extensions"
  }
]
```

| Field | Required | Purpose |
|---|---|---|
| `id` | yes | Drives the sidebar DOM id `nav-<id>` and the internal NAV_MAP key. Must be unique within the merged manifest — extension items with a core id replace the core entry in place (warning logged). |
| `label` | yes | Sidebar text and breadcrumb label. |
| `href` | yes | Navigation target. Extension pages typically use `/<name>.html`; core pages use `/core/tools/workspace/app/<name>.html`. |
| `section` | no | Section header. Defaults to `Navigation`. Any other string creates a new section appended after the core sections (in manifest order). |
| `aliases` | no | Additional paths that should highlight this nav item when loaded (e.g. `project-detail.html` highlights `Projects`). |
| `team` | no | The team this entry belongs to (used by team scoping — see below). Resolution order: this inline field > the `nav.<team>.json` filename it lives in > `null` (untagged). Untagged entries are shown on every device. |

**Endpoint:** `GET /__api/nav` returns `{ "items": [...] }` — the merged manifest in final order. The shell fetches this on load and renders the sidebar dynamically. Read-through cached with mtime invalidation; no restart needed after editing a manifest. If the endpoint fails, the shell falls back to a minimal Home-only nav rather than rendering a white page.

**Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), the item is dropped with a `[nav] hidden:` warning — `library-sync` only writes a page file when the owning repo is mounted here, so page-presence is the per-entry materialisation signal (external/deep hrefs whose shape can't be resolved are left untouched).

**Team scoping (display):** a device declares its display team in `operator/config/preferences.json:team` (e.g. `"team": "hq"`), reusing the same team-scope config the project surfaces read (`readTeamScopeConfig` in `packages/core/src/desk/team-scope.mjs`). The default view keeps entries whose team matches the device team **or** are untagged (`null`), and hides other teams' — so a device that is *entitled* to another team's tooling (its pages are on disk) still only *shows* its own team's nav. This is the entitlement-vs-display split. Defaults are fail-open and backwards compatible: a device with no `team`, or nav that carries no team tags, renders unfiltered exactly as before. `team` is resolved once at server start (a change is a restart, like the port beside it). Escape hatches mirror the desk/projects convention: `GET /__api/nav?allTeams=1` bypasses scoping entirely, and `?team=<id>` is a strict single-team inspection filter (untagged excluded).

**Example:** copy `core/tools/workspace/nav.json.example` to `operator/extensions/workspace/nav.json` and adjust for your pages. A worked demo:

```bash
# Create a new extension page
cat > operator/extensions/workspace/mypage.html <<'EOF'
<!DOCTYPE html><html><body><h1>My Page</h1></body></html>
EOF

# Register it in the nav
cat > operator/extensions/workspace/nav.json <<'EOF'
[{ "id": "mypage", "label": "My Page", "href": "/mypage.html", "section": "Extensions" }]
EOF

# Reload the browser — "My Page" appears in a new "Extensions" section.
```

### Live reload

Live reload was temporarily removed pending a workspace rebuild. Refresh the browser manually (Cmd+R / Ctrl+R) after editing core pages or extension overlays. See the PR "Remove live-reload SSE mechanism (temporary — pending workspace rebuild)" for context.

## Status page (daily status board)

`app/status-board.html` is the core **Status** page, second in the nav after Home. It shows one PA's daily status board as lanes side by side, full width, in this order: On me, For me, Main, every other lane the board's `lanes` section holds (in board order), Done. Each lane shows its title, its count and, when it has nothing in it, its empty state ("Nothing on you", "Nothing needs your eyes", "Lane idle" or "Lane paused", "Nothing finished in the last 24 hours"). Lanes keep a 280 px minimum and the page scrolls sideways on a narrow window.

- **Cards.** A header row with the one-liner (cut with an ellipsis) and, on a queued item with `after`, the "after <task>" tag at the top right; one row of three badges that never wraps (project, task id, status: the item's own status, else running or queued by its place; an unknown value shows as a grey chip with its raw text); a footer with the start and the estimated finish (amber once past, "not set" when the board holds none). A click on the task id copies it to the clipboard and shows "Copied"; the id is a button, so Tab and Enter or Space copy it too, and copying never opens the window. A click elsewhere on the card, or Enter or Space on the focused card, opens the task window (what and why, where, start and estimate, what happens next or what it produced, links): focus moves into it, Tab stays inside, Escape or Close shuts it and focus returns to the card.
- **For me.** Notices of kind Risk, Money or Security, each with Dismiss. Dismiss sends `POST /__api/status-board/dismiss[?agent=<name>]` with `{ "id": "<notice id>" }`; the route sets `dismissedAt` on that notice in the board file through the CLI's atomic write and changes nothing else (`updatedAt` included). It answers 200 `{ ok, id, dismissedAt }`, 400 for bad JSON, no id or a bad agent, 404 for no board or an unknown id, and 409 for a board that does not pass the schema. A dismissed notice is not drawn.
- **Data.** Each PA instance keeps its own board at `agents/<agent>/workspace/status-board.json` in the operator repo. The page reads it through `GET /__api/status-board`, which resolves the agent the same way `/__api/next-session` does (`paName` in preferences, else the single `agents/*` folder holding `next-session.md`, else `dave`). `?agent=<name>` on the page (passed through to the routes) shows another instance's board. The route's `board` is the schema v2 view (`toBoardV2`): `main` supplied first, every lane with a `queued` list, `done` kept to the 24 hours before `updatedAt`, and a v1 board migrated on read with its `groups` kept.
- **Schema.** One validator, `packages/cli/src/pa/status-board.mjs`, serves the routes and the CLI; its header comment is the schema. v2 adds `schemaVersion: 2`, `forMe` and `lanes`. The on-me list key is `onMe`; the legacy key and status value `onBaz` read as `onMe`.
- **Writes.** The PA writes the board only with `pa status set | patch | lane | notice | done` (validated, atomic temp-then-rename). `pa status patch --lane <key>` is the lanes seam: any writer that keeps a lane current uses it (or `pa status lane --key <key> --file -`). Bare `pa status` is still the environment probe.
- **Behaviour.** Refreshes every 60 seconds and when the tab comes back into view, without reloading. The freshness pill turns amber at 45 minutes and red at 90 since `updatedAt`. A bad file (unparseable or off-schema) keeps the last good copy on screen behind a red banner. No board yet says "No status board yet today", draws the fixed lanes empty and keeps checking. The open-dispatch banner names open dispatches that no group or lane item holds. Colours come from the shared theme tokens only, light and dark.

## Notes page (notes API)

`app/notes.html` is the core **Notes** page. It lists every note the PA and the operator keep, newest first, and opens each one in the viewer. The home page notes rail shows the first eight of the same list.

- **Roots.** `GET /__api/notes` walks these notes roots, in order:
  1. `operator/notes`, the instance's canonical notes directory (the scaffold creates it). Always first.
  2. `agents/<paName>/workspace/notes`, where the running PA writes its session notes beside its status board. `paName` resolves the same way the Status page does.
  3. Each repo-relative path in the preferences key `notesRoots` (an array of strings in `operator/config/preferences.json` or the local overlay), in the order given. Example: `"notesRoots": ["agents/<other-agent>/workspace/notes", "shared/notes"]`.

  With no `notesRoots` key the first two roots are walked; that is the default. Discovery covers the resolved PA only, not every `agents/*` folder, so a second agent's notes need a `notesRoots` entry. A configured root that is not a non-empty string, names the repo root itself or resolves outside the repo (by path or through a symlink) is refused: the server logs a warning and never walks it. A root named twice is walked once, and a note under a root nested inside an earlier root is listed once, under the earlier root.
- **What counts as a note.** In every root: files ending `.md` or `.html` (the viewer renders `.md` and serves `.html` as is), at any depth, except under a top-level `assets/` folder of that root. Nothing inside a file is read. The title is the filename without its extension and without one leading `discuss-`, `research-`, `draft-`, `spec-`, `pickup-` or `marketing-` prefix, hyphens as spaces, each word capitalised. A same-basename `.md` and `.html` pair in one folder lists as two notes; the page tags the `.html` one.
- **Response.**

  ```json
  {
    "notes": [
      { "name": "x.md", "title": "X", "path": "operator/notes/topic/x.md",
        "root": "operator/notes", "folder": "topic", "modified": "2026-01-02T03:04:05.000Z" }
    ],
    "notesDir": "present",
    "notesDirPath": "operator/notes",
    "roots": [
      { "path": "operator/notes", "state": "present", "count": 1 },
      { "path": "agents/<paName>/workspace/notes", "state": "missing", "count": 0 }
    ]
  }
  ```

  `notes` is one list, newest `modified` (file mtime) first across all roots, ties broken by `path` ascending. `path` is repo-relative, the value `viewer.html?file=` takes. `root` is the note's repo-relative root and `folder` is relative to that root (`''` at its top level). `notesDir` (`missing`, `empty` or `present`) and `notesDirPath` describe `operator/notes` alone. `roots` has one entry per root walked, with its own state and the count of notes listed under it. A failure is a 500 with `{ "error": "..." }`.
- **Page.** Each row shows its root under the title when more than one root is walked, and a chip per root (with its count) filters the list. Search matches the root, the folder, the title and, for fetched notes, the first lines of the content. With no notes at all, the empty state names every root walked and its state.

## Tests

```bash
node --test core/tools/workspace/test.mjs
```

Pass the explicit test file. `node --test <dir>` does not work on Node 24 — the runner expects a CommonJS entry in the directory and will error out without one.

## Requirements

- Node.js 24+
- No npm dependencies (node built-ins only)
