create-pmos 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -4
- package/bin/create-pmos.js +97 -8
- package/package.json +7 -3
- package/payload/AGENTS-HUMAN.md +6 -0
- package/payload/AGENTS.md +62 -3
- package/payload/KIT-VERSION +2 -2
- package/payload/README.md +16 -2
- package/payload/SECURITY.md +59 -0
- package/payload/okf/core/concepts/design-loop.md +6 -3
- package/payload/okf/core/concepts/frontmatter-format.md +75 -0
- package/payload/okf/core/concepts/index.md +3 -0
- package/payload/okf/core/concepts/log.md +13 -0
- package/payload/okf/core/templates/design-brief-template.md +1 -1
- package/payload/okf/core/templates/eval-rubric-template.md +12 -10
- package/payload/okf/core/templates/prd-template.md +6 -0
- package/payload/okf/core/templates/prototype-brief-template.md +45 -21
- package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
- package/payload/scripts/frontier.sh +60 -0
- package/payload/scripts/metrics.py +163 -102
- package/payload/scripts/pmos-log.sh +16 -6
- package/payload/skills/SKILL-FORMAT.md +72 -9
- package/payload/skills/discovery.skill +4 -0
- package/payload/skills/eval-rubric.skill +1 -1
- package/payload/skills/evaluator.skill +65 -20
- package/payload/skills/explain-run.skill +9 -6
- package/payload/skills/explain-surface.skill +156 -0
- package/payload/skills/ingest-repo.skill +4 -3
- package/payload/skills/pmos-router.skill +25 -3
- package/payload/skills/prd.skill +8 -0
- package/payload/skills/prototype.skill +106 -41
- package/payload/skills-native/copywriting/SKILL.md +57 -0
- package/payload/skills-native/discovery/SKILL.md +75 -0
- package/payload/skills-native/eval-rubric/SKILL.md +159 -0
- package/payload/skills-native/evaluator/SKILL.md +182 -0
- package/payload/skills-native/explain-run/SKILL.md +121 -0
- package/payload/skills-native/explain-surface/SKILL.md +158 -0
- package/payload/skills-native/ingest-repo/SKILL.md +120 -0
- package/payload/skills-native/okr/SKILL.md +70 -0
- package/payload/skills-native/pmos-router/SKILL.md +81 -0
- package/payload/skills-native/prd/SKILL.md +117 -0
- package/payload/skills-native/prototype/SKILL.md +147 -0
- package/payload/state/tickets/.gitkeep +0 -0
- package/payload/templates/agent-run-eval-rubric.md +10 -7
- package/payload/templates/ticket-template.md +20 -0
- package/workspace/README.md +112 -0
- package/workspace/REMOTE.md +131 -0
- package/workspace/bin/pmos.js +206 -0
- package/workspace/doctor.js +99 -0
- package/workspace/lib/console-state.js +117 -0
- package/workspace/lib/file-mode-parse.js +58 -0
- package/workspace/lib/harness-parse.js +222 -0
- package/workspace/lib/preview-kind.js +98 -0
- package/workspace/lib/rate-limit.js +26 -0
- package/workspace/lib/remote-access.js +235 -0
- package/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/schedule-logic.js +132 -0
- package/workspace/lib/skill-store.js +143 -0
- package/workspace/lib/surface-contract.js +79 -0
- package/workspace/lib/transcript-read.js +247 -0
- package/workspace/package-lock.json +94 -0
- package/workspace/package.json +23 -0
- package/workspace/public/app.js +1710 -0
- package/workspace/public/index.html +175 -0
- package/workspace/public/styles.css +766 -0
- package/workspace/server.js +1753 -0
- package/workspace/skills/backlog-triage.skill +13 -0
- package/workspace/skills/weekly-report.skill +19 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Reference
|
|
3
|
+
title: "PMOS Terminal Workspace"
|
|
4
|
+
timestamp: 2026-07-25
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PMOS Terminal Workspace
|
|
8
|
+
|
|
9
|
+
The local companion that runs the PM's agent sessions as **real terminals**, with the PMOS
|
|
10
|
+
workflow (library, gates, needs-you) around them. Built to D62 (as ratified + twice amended):
|
|
11
|
+
a **persistent, localhost-only session server** owning real PTYs, with a **GUI client** attached
|
|
12
|
+
over WebSocket. The native TUI is a deferred later client (D62 amendment 2).
|
|
13
|
+
|
|
14
|
+
## Why it can't lose your harness
|
|
15
|
+
Each session spawns your agent CLI (Claude Code / Codex / a shell) as a real PTY; xterm.js is a
|
|
16
|
+
true terminal emulator on the other end. The CLI runs **untouched** — its own login (Claude
|
|
17
|
+
subscription included), slash commands, keybindings. The workspace is the terminal, not a wrapper.
|
|
18
|
+
|
|
19
|
+
## Everything survives a session
|
|
20
|
+
The server holds PTYs in memory and mirrors each session's scrollback + metadata to `state/`.
|
|
21
|
+
Close/reload the client → re-attach replays scrollback and the run continues (detach, not kill).
|
|
22
|
+
Restart the server → prior sessions reload as `exited` with full history and one-click respawn.
|
|
23
|
+
|
|
24
|
+
## Run it — just type `pmos`
|
|
25
|
+
One-time setup (installs the `pmos` command on your machine):
|
|
26
|
+
```bash
|
|
27
|
+
cd workspace
|
|
28
|
+
npm install -g .
|
|
29
|
+
```
|
|
30
|
+
Then, from any terminal, any folder:
|
|
31
|
+
```bash
|
|
32
|
+
pmos # starts the workspace (installs deps on first run) + opens the browser
|
|
33
|
+
```
|
|
34
|
+
That's it. The server keeps running after you close the terminal — sessions survive (R7).
|
|
35
|
+
```bash
|
|
36
|
+
pmos status # is it running? which sessions?
|
|
37
|
+
pmos stop # shut it down (scrollback is preserved; sessions respawn next time)
|
|
38
|
+
pmos doctor # diagnose the environment (node-pty arch, PTY spawn test)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
<details><summary>Without the global install (running from the repo)</summary>
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd workspace
|
|
45
|
+
npm install # node-pty (native), ws, @xterm/*
|
|
46
|
+
npm start # http://127.0.0.1:7433 (localhost only — never public, D62)
|
|
47
|
+
npm test # end-to-end checks: PTY round-trip, restart-persistence, gates, launcher
|
|
48
|
+
npm run test:ui # browser checks: terminal geometry, reader scroll, markdown (needs Chrome)
|
|
49
|
+
```
|
|
50
|
+
</details>
|
|
51
|
+
|
|
52
|
+
## Layout (PM-directed, D62 R9 + round 2)
|
|
53
|
+
Three columns. **Left = the workspace**: repos on top, running agents below, equal halves.
|
|
54
|
+
**Middle = the terminal**, sessions as tabs. **Right rail = the library over the document
|
|
55
|
+
reader** — either pane folds away (click its header), the whole rail hides (`rail`), and both
|
|
56
|
+
the split and the rail width are draggable. The layout you choose survives a reload.
|
|
57
|
+
|
|
58
|
+
- **Add a repo by picking a folder** — `+` opens your machine's own folder chooser (typing a
|
|
59
|
+
path still works; where no chooser exists, an in-app folder browser stands in).
|
|
60
|
+
- **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or
|
|
61
|
+
exited, and it survives a server restart.
|
|
62
|
+
- **Documents read like documents** — the identity block becomes a card, tables are tables,
|
|
63
|
+
nested/ordered/task lists survive, and a relative `.md` link opens that document in the reader.
|
|
64
|
+
- **Preview more than markdown** — `.skill` files render as markdown; text/config files open as
|
|
65
|
+
escaped source; `.html` opens as source with an opt-in **sandboxed** render (scripts + network
|
|
66
|
+
denied); markdown renders **repo-relative images inline**. A remote `` image is
|
|
67
|
+
never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
|
|
68
|
+
|
|
69
|
+
## The gates are real, never automatic (R5)
|
|
70
|
+
`approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and
|
|
71
|
+
clear the matching needs entry — only ever on your click.
|
|
72
|
+
|
|
73
|
+
**The record lands in the repo that earned it.** Each workspace resolves its own state dir —
|
|
74
|
+
`pmos/state/` (the kit's layout, D54), then `state/`, then `.pmos/`; the home workspace uses the
|
|
75
|
+
server's own `state/`. A PMOS-shaped repo with no state dir yet gets the kit layout on first
|
|
76
|
+
write. That path matters: `scripts/metrics.py` reads `pmos/state/acceptances.jsonl` for the north
|
|
77
|
+
star and κ, so an acceptance recorded anywhere else is a green click over an empty record. A
|
|
78
|
+
plain (non-PMOS) repo has no layout to invent — the gate records into the server's `state/` and
|
|
79
|
+
the response says `fallback: true` rather than guessing.
|
|
80
|
+
|
|
81
|
+
Records carry the kit's `accepted` field (`accept` → true, `return` → false) so file-mode metrics
|
|
82
|
+
count them. `pm_label` is **never** derived from a click — κ labels are the PM's own word (D40).
|
|
83
|
+
|
|
84
|
+
## Your own runs are searchable (the history lens)
|
|
85
|
+
Every session's scrollback is already mirrored to disk, which makes it a record of what earlier
|
|
86
|
+
runs actually **did** — the thing structured run rows don't carry. The rail's fourth lens,
|
|
87
|
+
**history**, searches it; the filter box becomes the query. An agent working in a pane can ask
|
|
88
|
+
the same question itself: `GET /api/search?q=…&ws=…`.
|
|
89
|
+
|
|
90
|
+
Two guarantees. It is scoped to **one repo** — a workspace never searches another's history
|
|
91
|
+
(R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less.
|
|
92
|
+
And every returned line is **scrubbed** of ANSI and of anything credential-shaped (bearer
|
|
93
|
+
tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the agent printed,
|
|
94
|
+
so D08's never-log-headers discipline applies to what this hands back. Matching runs against
|
|
95
|
+
the *scrubbed* text, so the endpoint cannot be used to grep a secret out.
|
|
96
|
+
|
|
97
|
+
## Boundaries (honest v1 seams)
|
|
98
|
+
- **Server binds 127.0.0.1** — the D62 hosted-PTY-bridge rejection stands; a tailnet/phone client
|
|
99
|
+
attaches to the same local server, never a public endpoint.
|
|
100
|
+
- **needs-you feed** — an explicit `needs.json` (written by the harness/MCP) is authoritative for
|
|
101
|
+
the entries it names; everything else is **derived** from the repo's own append-only state, so a
|
|
102
|
+
repo nobody has seeded still shows its real work. Derived: an eval with no acceptance for its run
|
|
103
|
+
(the Acceptance Gate is open, D12), and a declared outcome past its horizon with no disposition
|
|
104
|
+
(the D57 check is due — a *missing* horizon counts as due, mirroring `metrics.py`). Derivation
|
|
105
|
+
only reports: an overdue outcome carries **no** close button, because disposal is
|
|
106
|
+
`record_outcome_check` with evidence, and auto-disposal is prohibited (D57).
|
|
107
|
+
- File reads are **path-guarded** to the repo. The reader serves previewable **text kinds**
|
|
108
|
+
(markdown incl. `.skill`, text/config, HTML source) and streams repo-relative **images** through
|
|
109
|
+
a MIME-allowlisted `/api/asset`; binaries and out-of-root/cross-workspace paths are refused.
|
|
110
|
+
Remote images are never fetched (no beacon). Request headers are never logged (D08).
|
|
111
|
+
|
|
112
|
+
Screenshots: `docs/screenshot-dark.png`, `docs/screenshot-light.png`, `docs/screenshot-doc.png`.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Reference
|
|
3
|
+
title: "Workspace remote access over a private tailnet (Tailscale)"
|
|
4
|
+
timestamp: 2026-07-26
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Reaching the workspace from your phone — over a private tailnet, never the public internet
|
|
8
|
+
|
|
9
|
+
The workspace server owns **real terminal sessions (PTYs)** running your `claude` on **your own
|
|
10
|
+
subscription**. By default it binds `127.0.0.1` only, so it is reachable solely from the machine it
|
|
11
|
+
runs on. This runbook enables reaching **the same local server** from another of your own devices —
|
|
12
|
+
a phone, a laptop — over a **private Tailscale tailnet**. It never exposes a public endpoint.
|
|
13
|
+
|
|
14
|
+
## Why a tailnet, and why no public endpoint (D62)
|
|
15
|
+
|
|
16
|
+
A [Tailscale](https://tailscale.com) tailnet is a private [WireGuard](https://www.wireguard.com/)
|
|
17
|
+
mesh between **your own devices**. Every device is authenticated and encrypted end-to-end; addresses
|
|
18
|
+
live in the CGNAT range `100.64.0.0/10` and are routable **only** between devices on your tailnet —
|
|
19
|
+
never from the public internet.
|
|
20
|
+
|
|
21
|
+
PMOS's locked decision **D62** rejects, permanently:
|
|
22
|
+
|
|
23
|
+
- **any public endpoint** for the workspace server, and
|
|
24
|
+
- **any hosted PTY bridge** (proxying the terminal through a hosted, browser-reachable surface).
|
|
25
|
+
|
|
26
|
+
A public server that owns a PTY running your Claude subscription is severe exposure. The tailnet path
|
|
27
|
+
sidesteps that entirely: the phone attaches to the *same local server* across your own private
|
|
28
|
+
network. Accordingly, this feature will **only ever** bind loopback plus your machine's own tailnet
|
|
29
|
+
address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it **refuses to start**.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
1. **Install Tailscale** on the machine running the workspace and on your phone:
|
|
34
|
+
- Machine: https://tailscale.com/download — then `tailscale up`.
|
|
35
|
+
- Phone: install the Tailscale app from your app store and sign in to the **same** tailnet.
|
|
36
|
+
- Confirm the machine's tailnet IPv4: `tailscale ip -4` (a `100.x.y.z` address).
|
|
37
|
+
|
|
38
|
+
2. **Start the server in remote mode**, from `workspace/`:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
PMOS_REMOTE=tailnet npm start
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On startup it prints (once):
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pmos workspace server — REMOTE (tailnet) mode
|
|
48
|
+
local: http://127.0.0.1:7433
|
|
49
|
+
tailnet: http://100.x.y.z:7433 (private WireGuard net, device-authenticated, never public — D62)
|
|
50
|
+
token (generated — shown ONCE, copy it now): <a long token>
|
|
51
|
+
attach with header Authorization: Bearer <token> or query ?token=<token>
|
|
52
|
+
loopback requests are token-free; every tailnet request must present the token.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Copy the token now** — it is shown only once and never logged again.
|
|
56
|
+
|
|
57
|
+
3. **Open it from your phone.** In the phone's browser (on the same tailnet), open:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
http://100.x.y.z:7433/?token=<token>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The client attaches to the **same live sessions** the desktop sees; the token authorizes both the
|
|
64
|
+
page and its WebSocket. Requests from the machine itself (`127.0.0.1`) never need the token.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
| Variable | Effect |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `PMOS_REMOTE=tailnet` | Opt in. Bind loopback **+** the machine's tailnet IPv4, and require a token beyond loopback. |
|
|
71
|
+
| `PMOS_BIND_TAILNET=1` | Equivalent to `PMOS_REMOTE=tailnet`. |
|
|
72
|
+
| `PMOS_TOKEN=<t>` | Use your own token instead of a generated one. **Never printed** — supply it yourself on both ends. |
|
|
73
|
+
| `PMOS_TAILNET_IP=<100.x.y.z>` | Pin the tailnet address (skips auto-detection). Must be within `100.64.0.0/10`. |
|
|
74
|
+
| `PMOS_BIND_ADDR=<addr>` | Force a specific bind. Accepted **only** if loopback or within `100.64.0.0/10`; anything else refuses to start. |
|
|
75
|
+
| *(none of the above)* | **Default:** loopback only, no token — unchanged local behavior. |
|
|
76
|
+
|
|
77
|
+
## Security model (what is guaranteed)
|
|
78
|
+
|
|
79
|
+
- **Never public.** The only addresses the server will ever bind are loopback and a
|
|
80
|
+
`100.64.0.0/10` tailnet address. `0.0.0.0`, `::`, LAN, and public IPs are refused — the server
|
|
81
|
+
exits with a clear error rather than binding them. There is no code path to a public bind.
|
|
82
|
+
- **Token beyond loopback.** With remote mode on, every non-loopback HTTP request and WebSocket
|
|
83
|
+
upgrade must present the bearer token, or it is rejected (`401` / socket closed). Loopback requests
|
|
84
|
+
stay token-free for local development. The comparison is constant-time.
|
|
85
|
+
- **Origin/Host allowlist (D64).** Both the HTTP guard and the WebSocket-upgrade handshake reject any
|
|
86
|
+
request whose `Origin` is foreign (not same-origin with the `Host` it arrived on) or whose `Host`
|
|
87
|
+
hostname is not loopback/localhost or your tailnet address. This closes the cross-origin-localhost /
|
|
88
|
+
DNS-rebinding vector: without it, **any website open in your browser** could open a WebSocket to
|
|
89
|
+
`ws://127.0.0.1:<port>` and spawn a shell + type into it, because loopback peers are token-exempt.
|
|
90
|
+
A same-origin page served by the workspace passes; a cross-site page is rejected `403`. Native
|
|
91
|
+
clients / `curl` (which send no `Origin`) still pass this check and are then gated by the token.
|
|
92
|
+
- **No header/token logging (D08).** The request-guard path logs nothing about the request. The
|
|
93
|
+
token is printed at most once at startup (only when generated); a token you supply is never echoed.
|
|
94
|
+
- **Rate-limited control channel (D64).** The WS messages that spawn shells (`new`/`respawn`) and
|
|
95
|
+
feed a PTY (`input`) are per-connection rate-limited, bounding abuse if the socket is ever reached.
|
|
96
|
+
- **Independent layers.** The tailnet (WireGuard device auth), the bearer token, and the origin/host
|
|
97
|
+
allowlist all gate access — a device must be *on your tailnet*, *hold the token*, **and** present a
|
|
98
|
+
same-origin request from an allowed host.
|
|
99
|
+
|
|
100
|
+
## Exposing to the internet — the required hardening (D64)
|
|
101
|
+
|
|
102
|
+
The workspace is **tailnet-first**. The tailnet path above is the supported way to reach it from
|
|
103
|
+
another of your own devices and needs no internet exposure. If you nonetheless expose an instance to
|
|
104
|
+
the internet, D64 permits it **only after all three of the following are in place — never before, and
|
|
105
|
+
never as a raw public bind**:
|
|
106
|
+
|
|
107
|
+
1. **The bearer token for every non-loopback peer** (already enforced in remote mode).
|
|
108
|
+
2. **The Origin/Host allowlist** (already enforced — see above).
|
|
109
|
+
3. **TLS termination at the exposure boundary.** **The workspace server does not terminate TLS.** It
|
|
110
|
+
speaks plain HTTP/WS and must sit behind something that terminates TLS for it — Tailscale's own
|
|
111
|
+
HTTPS (`tailscale serve` / `tailscale funnel`) or a reverse proxy (Caddy, nginx, a tunnel) that
|
|
112
|
+
holds the certificate and forwards to the loopback/tailnet listener. Do **not** put the raw HTTP
|
|
113
|
+
port on a public interface — the server refuses non-loopback/non-tailnet binds precisely so this
|
|
114
|
+
stays a conscious proxy decision, not an accident.
|
|
115
|
+
|
|
116
|
+
When a reverse proxy forwards to the workspace, keep the forwarded `Host` set to the loopback/tailnet
|
|
117
|
+
host the server actually binds, so the Origin/Host allowlist still recognises same-origin requests.
|
|
118
|
+
|
|
119
|
+
## Troubleshooting
|
|
120
|
+
|
|
121
|
+
- **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"**
|
|
122
|
+
— you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real
|
|
123
|
+
`tailscale ip -4` value.
|
|
124
|
+
- **"no Tailscale tailnet address … was found"** — Tailscale isn't up on this machine. Run
|
|
125
|
+
`tailscale up`, or set `PMOS_TAILNET_IP`.
|
|
126
|
+
- **"cannot bind 100.x.y.z:PORT — that address is not present on this machine"** — the pinned
|
|
127
|
+
`PMOS_TAILNET_IP` isn't actually assigned to this machine. Check `tailscale ip -4`.
|
|
128
|
+
- **Phone gets 401** — the token is missing or wrong. Re-open with `?token=<token>` (or send
|
|
129
|
+
`Authorization: Bearer <token>`). Restarting the server without `PMOS_TOKEN` mints a new token.
|
|
130
|
+
- **Phone can't connect at all** — confirm both devices show each other in the Tailscale app, and
|
|
131
|
+
that you used the `100.x.y.z` address, not a LAN IP.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* pmos — one-word launcher for the PMOS Terminal Workspace (Herdr-style).
|
|
3
|
+
*
|
|
4
|
+
* pmos open THIS directory's repo as a workspace (starting the server if needed)
|
|
5
|
+
* pmos <path> open another directory as a workspace
|
|
6
|
+
* pmos status is the server running? which workspaces, which sessions?
|
|
7
|
+
* pmos ls list the registered workspaces
|
|
8
|
+
* pmos stop stop the server (running sessions end — it says so first)
|
|
9
|
+
* pmos doctor diagnose the environment (node-pty arch, PTY spawn, …)
|
|
10
|
+
*
|
|
11
|
+
* The directory you type `pmos` in IS the workspace: it is resolved to its git root and
|
|
12
|
+
* registered with the server, and the browser opens focused on it. One server hosts many
|
|
13
|
+
* repos — opening a second repo never disturbs the first one's running sessions.
|
|
14
|
+
*
|
|
15
|
+
* The server is started DETACHED: closing this terminal does not kill it — sessions
|
|
16
|
+
* keep running until `pmos stop` (the persistent-server model, R7). Dependency-free.
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
const http = require('http');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawn, spawnSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const WS = path.resolve(__dirname, '..'); // workspace/
|
|
25
|
+
const STATE = path.join(WS, 'state');
|
|
26
|
+
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
27
|
+
// pid/log are PER-PORT: a second workspace (or a test server) on another port must never clobber
|
|
28
|
+
// this one's pid file — that is exactly how `pmos stop` was left unable to find the real server.
|
|
29
|
+
const PIDFILE = path.join(STATE, `server-${PORT}.pid`);
|
|
30
|
+
const LOGFILE = path.join(STATE, `server-${PORT}.log`);
|
|
31
|
+
const URL_ = `http://127.0.0.1:${PORT}`;
|
|
32
|
+
const NO_OPEN = process.env.PMOS_NO_OPEN === '1'; // tests set this
|
|
33
|
+
|
|
34
|
+
const say = (s) => console.log(s);
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
|
|
37
|
+
function probe() {
|
|
38
|
+
return new Promise((res) => {
|
|
39
|
+
const req = http.get(`${URL_}/api/sessions`, { timeout: 900 }, (r) => {
|
|
40
|
+
let b = ''; r.on('data', (c) => (b += c));
|
|
41
|
+
r.on('end', () => { try { res(JSON.parse(b)); } catch { res(null); } });
|
|
42
|
+
});
|
|
43
|
+
req.on('error', () => res(null));
|
|
44
|
+
req.on('timeout', () => { req.destroy(); res(null); });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function api(method, pathname, body) {
|
|
48
|
+
return new Promise((res) => {
|
|
49
|
+
const data = body ? JSON.stringify(body) : null;
|
|
50
|
+
const req = http.request(`${URL_}${pathname}`, {
|
|
51
|
+
method, timeout: 3000,
|
|
52
|
+
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
|
53
|
+
}, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => { try { res(JSON.parse(b)); } catch { res(null); } }); });
|
|
54
|
+
req.on('error', () => res(null));
|
|
55
|
+
req.on('timeout', () => { req.destroy(); res(null); });
|
|
56
|
+
if (data) req.write(data);
|
|
57
|
+
req.end();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The workspace is the REPO you are in, not the folder you happen to stand in — so `pmos`
|
|
62
|
+
// from a subdirectory opens the whole repo, the same way every git tool behaves.
|
|
63
|
+
function repoRootOf(dir) {
|
|
64
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
|
|
65
|
+
const top = r.status === 0 ? String(r.stdout).trim() : '';
|
|
66
|
+
return top && fs.existsSync(top) ? top : path.resolve(dir);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openBrowser(url) {
|
|
70
|
+
if (NO_OPEN) return;
|
|
71
|
+
const cmd = process.platform === 'darwin' ? ['open', [url]]
|
|
72
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
73
|
+
: ['xdg-open', [url]];
|
|
74
|
+
try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ensureInstalled() {
|
|
78
|
+
if (fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) return true;
|
|
79
|
+
say('First run — installing the workspace (one-time, ~20s)…');
|
|
80
|
+
const r = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
81
|
+
['install', '--no-audit', '--no-fund'], { cwd: WS, stdio: 'inherit' });
|
|
82
|
+
if (r.status !== 0) {
|
|
83
|
+
say('✗ install failed. Run `npm run doctor` inside the workspace folder for the exact fix.');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Register the target directory and open the browser ON IT. The `focus` broadcast matters:
|
|
90
|
+
// the OS may refocus an already-open tab instead of opening our URL, and without it the PM
|
|
91
|
+
// would be looking at the previously-opened repo — the defect this replaces.
|
|
92
|
+
async function openWorkspace(dir) {
|
|
93
|
+
const root = repoRootOf(dir);
|
|
94
|
+
const reg = await api('POST', '/api/workspaces', { root, name: path.basename(root) });
|
|
95
|
+
if (!reg || !reg.ok) {
|
|
96
|
+
say(`✗ could not open ${root}${reg && reg.error ? ` — ${reg.error}` : ''}`);
|
|
97
|
+
openBrowser(URL_);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const w = reg.workspace;
|
|
101
|
+
say(`✓ workspace “${w.name}” — ${URL_}?ws=${w.id}`);
|
|
102
|
+
say(` ${root}`);
|
|
103
|
+
openBrowser(`${URL_}?ws=${w.id}`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function up(dir) {
|
|
108
|
+
if (await probe()) return openWorkspace(dir);
|
|
109
|
+
if (!ensureInstalled()) return 1;
|
|
110
|
+
fs.mkdirSync(STATE, { recursive: true });
|
|
111
|
+
const log = fs.openSync(LOGFILE, 'a');
|
|
112
|
+
const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
|
|
113
|
+
cwd: WS, detached: true, stdio: ['ignore', log, log], env: process.env,
|
|
114
|
+
});
|
|
115
|
+
child.unref();
|
|
116
|
+
try { fs.writeFileSync(PIDFILE, String(child.pid)); } catch {}
|
|
117
|
+
process.stdout.write('starting the workspace ');
|
|
118
|
+
for (let i = 0; i < 40; i++) { // up to ~10s
|
|
119
|
+
if (await probe()) {
|
|
120
|
+
say('');
|
|
121
|
+
say(' sessions keep running after you close this terminal · `pmos stop` shuts it down');
|
|
122
|
+
return openWorkspace(dir);
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write('.');
|
|
125
|
+
await sleep(250);
|
|
126
|
+
}
|
|
127
|
+
say(`\n✗ the server did not come up. Its log says:`);
|
|
128
|
+
try { say(' ' + fs.readFileSync(LOGFILE, 'utf8').trim().split('\n').slice(-6).join('\n ')); } catch {}
|
|
129
|
+
say(' Run `pmos doctor` for a diagnosis.');
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function status() {
|
|
134
|
+
const s = await probe();
|
|
135
|
+
if (!s) { say(`○ not running (start it with: pmos)`); return 1; }
|
|
136
|
+
const wl = (await api('GET', '/api/workspaces')) || { workspaces: [] };
|
|
137
|
+
const live = s.sessions.filter((x) => x.state !== 'exited').length;
|
|
138
|
+
say(`✓ running — ${URL_}`);
|
|
139
|
+
say(` ${wl.workspaces.length} workspace(s): ${wl.workspaces.map((w) => w.name).join(' · ') || 'none'}`);
|
|
140
|
+
say(` ${s.sessions.length} session(s), ${live} live: ${s.sessions.map((x) => `${x.name}[${x.state}]`).join(' · ') || 'none'}`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function ls() {
|
|
145
|
+
if (!(await probe())) { say('○ not running (start it with: pmos)'); return 1; }
|
|
146
|
+
const wl = (await api('GET', '/api/workspaces')) || { workspaces: [] };
|
|
147
|
+
if (!wl.workspaces.length) { say('no workspaces registered.'); return 0; }
|
|
148
|
+
for (const w of wl.workspaces) {
|
|
149
|
+
say(`${w.home ? '★' : '·'} ${w.name} ${w.sessions} session(s) [${w.shape}]`);
|
|
150
|
+
say(` ${w.root}`);
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The pid of whatever is LISTENING on our port — the fallback when the pid file is missing
|
|
156
|
+
// (a crash, or a stale run that removed it). posix only; Windows keeps the manual message.
|
|
157
|
+
function listenerPid() {
|
|
158
|
+
if (process.platform === 'win32') return null;
|
|
159
|
+
try {
|
|
160
|
+
const r = spawnSync('lsof', ['-ti', `tcp:${PORT}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
|
|
161
|
+
const pid = Number(String(r.stdout || '').split('\n')[0].trim());
|
|
162
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function stop() {
|
|
167
|
+
if (!(await probe())) { say('○ not running.'); try { fs.unlinkSync(PIDFILE); } catch {} return 0; }
|
|
168
|
+
let pid = null;
|
|
169
|
+
try { pid = Number(fs.readFileSync(PIDFILE, 'utf8').trim()); } catch {}
|
|
170
|
+
if (!pid) pid = listenerPid(); // pid file lost — find the server by the port it owns
|
|
171
|
+
if (!pid) { say(`✗ could not locate the server process (no pid file, and nothing I can see owns port ${PORT}). Stop it manually.`); return 1; }
|
|
172
|
+
// sessions are tmux-backed: stopping the server leaves them running; the next `pmos` re-attaches.
|
|
173
|
+
say('stopping the workspace — sessions keep running in tmux and re-attach on the next launch…');
|
|
174
|
+
try { process.kill(pid); } catch (e) { say(`✗ could not signal pid ${pid}: ${e.message}`); return 1; }
|
|
175
|
+
for (let i = 0; i < 20; i++) { if (!(await probe())) { say('✓ stopped.'); try { fs.unlinkSync(PIDFILE); } catch {} return 0; } await sleep(250); }
|
|
176
|
+
say('✗ still responding after 5s — kill it manually.'); return 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function doctor() {
|
|
180
|
+
const r = spawnSync(process.execPath, [path.join(WS, 'doctor.js')], { stdio: 'inherit' });
|
|
181
|
+
return r.status || 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
(async () => {
|
|
185
|
+
const arg = process.argv[2];
|
|
186
|
+
const cmd = (arg || 'up').toLowerCase();
|
|
187
|
+
let code = 0;
|
|
188
|
+
if (cmd === 'status') code = await status();
|
|
189
|
+
else if (cmd === 'ls' || cmd === 'list') code = await ls();
|
|
190
|
+
else if (cmd === 'stop') code = await stop();
|
|
191
|
+
else if (cmd === 'doctor') code = doctor();
|
|
192
|
+
else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
193
|
+
say('usage: pmos [<path>|status|ls|stop|doctor]');
|
|
194
|
+
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
195
|
+
say(' pmos <path> open another repo as a workspace');
|
|
196
|
+
code = 0;
|
|
197
|
+
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
|
198
|
+
// `pmos open ../other-repo` — the path is the argument AFTER the verb
|
|
199
|
+
code = await up(process.argv[3] ? path.resolve(process.argv[3]) : process.cwd());
|
|
200
|
+
} else if (arg && !arg.startsWith('-')) {
|
|
201
|
+
code = await up(path.resolve(arg)); // `pmos ../other-repo`
|
|
202
|
+
} else {
|
|
203
|
+
code = await up(process.cwd());
|
|
204
|
+
}
|
|
205
|
+
process.exit(code);
|
|
206
|
+
})();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* pmos workspace doctor — diagnoses the environment the server needs.
|
|
3
|
+
* Checks, in order: Node version → node-pty loads → native binary arch matches the Node
|
|
4
|
+
* process → a real PTY actually spawns and echoes. Prints the exact fix for each failure.
|
|
5
|
+
* Exit 0 = healthy; exit 1 = something needs fixing (the output says what). */
|
|
6
|
+
'use strict';
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const checks = [];
|
|
12
|
+
const ok = (name, extra) => { checks.push(true); console.log(`✓ ${name}${extra ? ' — ' + extra : ''}`); };
|
|
13
|
+
const bad = (name, fix) => { checks.push(false); console.log(`✗ ${name}`); if (fix) console.log(` fix: ${fix}`); };
|
|
14
|
+
|
|
15
|
+
// Read the CPU arch a compiled binary was built for, from its magic bytes (no external tools).
|
|
16
|
+
function binaryArch(file) {
|
|
17
|
+
try {
|
|
18
|
+
const b = Buffer.alloc(8);
|
|
19
|
+
const fd = fs.openSync(file, 'r'); fs.readSync(fd, b, 0, 8, 0); fs.closeSync(fd);
|
|
20
|
+
if (b[0] === 0x7f && b[1] === 0x45) return { x: 'ELF', arch: b[4] === 2 ? (b.readUInt16LE ? 'elf64' : 'elf') : 'elf32' };
|
|
21
|
+
const m = b.readUInt32LE(0);
|
|
22
|
+
if (m === 0xfeedfacf) { const cpu = b.readUInt32LE(4); return { x: 'Mach-O', arch: cpu === 0x0100000c ? 'arm64' : cpu === 0x01000007 ? 'x86_64' : 'cpu:' + cpu.toString(16) }; }
|
|
23
|
+
if (m === 0xbebafeca || m === 0xcafebabe) return { x: 'Mach-O universal', arch: 'universal' };
|
|
24
|
+
if (b[0] === 0x4d && b[1] === 0x5a) return { x: 'PE', arch: 'windows' };
|
|
25
|
+
} catch {}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
(async () => {
|
|
30
|
+
console.log(`pmos workspace doctor — node ${process.version} (${process.platform}/${process.arch})\n`);
|
|
31
|
+
|
|
32
|
+
// 1. Node version
|
|
33
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
34
|
+
if (major >= 18) ok(`Node ${process.version} (>= 18)`);
|
|
35
|
+
else bad(`Node ${process.version} is too old`, 'install Node 18+ (arm64-native on Apple Silicon)');
|
|
36
|
+
|
|
37
|
+
// 2. node-pty loads
|
|
38
|
+
let pty = null;
|
|
39
|
+
try { pty = require('node-pty'); ok('node-pty native module loads'); }
|
|
40
|
+
catch (e) {
|
|
41
|
+
bad(`node-pty failed to load: ${e.message.split('\n')[0]}`,
|
|
42
|
+
'npm rebuild node-pty --build-from-source (needs: macOS `xcode-select --install`; Linux `apt install build-essential python3`)');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. native binary arch matches the running Node (the posix_spawnp culprit)
|
|
46
|
+
const helper = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release',
|
|
47
|
+
process.platform === 'win32' ? 'pty.node' : 'spawn-helper');
|
|
48
|
+
const alt = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release', 'pty.node');
|
|
49
|
+
const bin = fs.existsSync(helper) ? helper : (fs.existsSync(alt) ? alt : null);
|
|
50
|
+
if (!bin) bad('node-pty native binary not found (build incomplete)', 'rm -rf node_modules && npm install');
|
|
51
|
+
else {
|
|
52
|
+
const info = binaryArch(bin);
|
|
53
|
+
const want = process.arch; // arm64 | x64 | …
|
|
54
|
+
const got = info && info.arch;
|
|
55
|
+
const match = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
|
|
56
|
+
(want === 'arm64' && got === 'arm64') || (want === 'x64' && got === 'x86_64');
|
|
57
|
+
if (match) ok(`native binary present${got && got !== 'universal' && info.x === 'Mach-O' ? ` (${got})` : ''}`);
|
|
58
|
+
else bad(`ARCH MISMATCH: Node is ${want} but node-pty's binary is ${got} — this causes 'posix_spawnp failed' on every session`,
|
|
59
|
+
`install a ${want}-native Node (e.g. \`brew install node\` on an arm64 shell), then: rm -rf node_modules && npm install`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 4. a real PTY spawns and echoes
|
|
63
|
+
if (pty) {
|
|
64
|
+
const sh = process.platform === 'win32' ? (process.env.COMSPEC || 'powershell.exe') : '/bin/sh';
|
|
65
|
+
try {
|
|
66
|
+
const out = await new Promise((resolve, reject) => {
|
|
67
|
+
let acc = '';
|
|
68
|
+
const p = pty.spawn(sh, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: __dirname, env: process.env });
|
|
69
|
+
const t = setTimeout(() => { try { p.kill(); } catch {}; reject(new Error('timeout — shell spawned but never echoed')); }, 4000);
|
|
70
|
+
p.onData((d) => { acc += d; if (acc.includes('DOCTOR_OK')) { clearTimeout(t); try { p.kill(); } catch {}; resolve(acc); } });
|
|
71
|
+
p.onExit(() => { if (!acc.includes('DOCTOR_OK')) { clearTimeout(t); reject(new Error(`shell exited before echoing (got ${acc.length} bytes)`)); } });
|
|
72
|
+
p.write('echo DOCTOR_OK\r');
|
|
73
|
+
});
|
|
74
|
+
ok(`real PTY spawns + echoes (${sh})`);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
bad(`PTY spawn failed: ${e.message}`,
|
|
77
|
+
/posix_spawnp/.test(e.message)
|
|
78
|
+
? 'this is the broken-native-build signature — npm rebuild node-pty --build-from-source (and see the arch check above)'
|
|
79
|
+
: process.platform === 'win32' ? 'native Windows needs WSL for a real PTY — run the workspace inside WSL'
|
|
80
|
+
: 'check that /bin/sh exists and is executable');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. tmux — the session-persistence backing. Not fatal: absent, the server falls back to
|
|
85
|
+
// direct PTYs (sessions still work, but a `pmos stop`/restart ends them). Present, a session
|
|
86
|
+
// OUTLIVES a server restart and is re-attached live. This check explains which mode you're in.
|
|
87
|
+
const tmuxV = spawnSync('tmux', ['-V'], { encoding: 'utf8' });
|
|
88
|
+
if (tmuxV.status === 0) {
|
|
89
|
+
ok(`tmux present (${String(tmuxV.stdout).trim()}) — sessions PERSIST across \`pmos stop\`/restart`);
|
|
90
|
+
} else {
|
|
91
|
+
// a soft note, not a ✗: everything still works, just without restart-proof sessions
|
|
92
|
+
console.log('• tmux not found — sessions run on direct PTYs and END on `pmos stop`/restart (no live persistence).');
|
|
93
|
+
console.log(' optional fix: install tmux (macOS `brew install tmux`; Linux `apt install tmux`) for restart-proof sessions.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fails = checks.filter((c) => !c).length;
|
|
97
|
+
console.log(`\n${checks.length - fails}/${checks.length} checks passed${fails ? ' — fix the ✗ items above, then re-run npm run doctor' : ' — environment healthy, npm start away'}`);
|
|
98
|
+
process.exit(fails ? 1 : 0);
|
|
99
|
+
})();
|