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.
Files changed (67) hide show
  1. package/README.md +149 -4
  2. package/bin/create-pmos.js +97 -8
  3. package/package.json +7 -3
  4. package/payload/AGENTS-HUMAN.md +6 -0
  5. package/payload/AGENTS.md +62 -3
  6. package/payload/KIT-VERSION +2 -2
  7. package/payload/README.md +16 -2
  8. package/payload/SECURITY.md +59 -0
  9. package/payload/okf/core/concepts/design-loop.md +6 -3
  10. package/payload/okf/core/concepts/frontmatter-format.md +75 -0
  11. package/payload/okf/core/concepts/index.md +3 -0
  12. package/payload/okf/core/concepts/log.md +13 -0
  13. package/payload/okf/core/templates/design-brief-template.md +1 -1
  14. package/payload/okf/core/templates/eval-rubric-template.md +12 -10
  15. package/payload/okf/core/templates/prd-template.md +6 -0
  16. package/payload/okf/core/templates/prototype-brief-template.md +45 -21
  17. package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
  18. package/payload/scripts/frontier.sh +60 -0
  19. package/payload/scripts/metrics.py +163 -102
  20. package/payload/scripts/pmos-log.sh +16 -6
  21. package/payload/skills/SKILL-FORMAT.md +72 -9
  22. package/payload/skills/discovery.skill +4 -0
  23. package/payload/skills/eval-rubric.skill +1 -1
  24. package/payload/skills/evaluator.skill +65 -20
  25. package/payload/skills/explain-run.skill +9 -6
  26. package/payload/skills/explain-surface.skill +156 -0
  27. package/payload/skills/ingest-repo.skill +4 -3
  28. package/payload/skills/pmos-router.skill +25 -3
  29. package/payload/skills/prd.skill +8 -0
  30. package/payload/skills/prototype.skill +106 -41
  31. package/payload/skills-native/copywriting/SKILL.md +57 -0
  32. package/payload/skills-native/discovery/SKILL.md +75 -0
  33. package/payload/skills-native/eval-rubric/SKILL.md +159 -0
  34. package/payload/skills-native/evaluator/SKILL.md +182 -0
  35. package/payload/skills-native/explain-run/SKILL.md +121 -0
  36. package/payload/skills-native/explain-surface/SKILL.md +158 -0
  37. package/payload/skills-native/ingest-repo/SKILL.md +120 -0
  38. package/payload/skills-native/okr/SKILL.md +70 -0
  39. package/payload/skills-native/pmos-router/SKILL.md +81 -0
  40. package/payload/skills-native/prd/SKILL.md +117 -0
  41. package/payload/skills-native/prototype/SKILL.md +147 -0
  42. package/payload/state/tickets/.gitkeep +0 -0
  43. package/payload/templates/agent-run-eval-rubric.md +10 -7
  44. package/payload/templates/ticket-template.md +20 -0
  45. package/workspace/README.md +112 -0
  46. package/workspace/REMOTE.md +131 -0
  47. package/workspace/bin/pmos.js +206 -0
  48. package/workspace/doctor.js +99 -0
  49. package/workspace/lib/console-state.js +117 -0
  50. package/workspace/lib/file-mode-parse.js +58 -0
  51. package/workspace/lib/harness-parse.js +222 -0
  52. package/workspace/lib/preview-kind.js +98 -0
  53. package/workspace/lib/rate-limit.js +26 -0
  54. package/workspace/lib/remote-access.js +235 -0
  55. package/workspace/lib/review-gate.js +39 -0
  56. package/workspace/lib/schedule-logic.js +132 -0
  57. package/workspace/lib/skill-store.js +143 -0
  58. package/workspace/lib/surface-contract.js +79 -0
  59. package/workspace/lib/transcript-read.js +247 -0
  60. package/workspace/package-lock.json +94 -0
  61. package/workspace/package.json +23 -0
  62. package/workspace/public/app.js +1710 -0
  63. package/workspace/public/index.html +175 -0
  64. package/workspace/public/styles.css +766 -0
  65. package/workspace/server.js +1753 -0
  66. package/workspace/skills/backlog-triage.skill +13 -0
  67. 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 `![](https://…)` 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
+ })();