create-pmos 0.15.0 → 0.16.1

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 (39) hide show
  1. package/README.md +65 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +50 -13
  5. package/workspace/bin/pmos.js +204 -51
  6. package/workspace/design-parity.js +358 -0
  7. package/workspace/doctor.js +54 -0
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/four-panel.js +345 -0
  10. package/workspace/lib/git-info.js +28 -1
  11. package/workspace/lib/mobile-nav.js +32 -2
  12. package/workspace/lib/server-discovery.js +127 -0
  13. package/workspace/package-lock.json +29 -2
  14. package/workspace/package.json +13 -5
  15. package/workspace/public/index.html +79 -409
  16. package/workspace/public/pm/app.js +242 -0
  17. package/workspace/public/pm/data.js +905 -0
  18. package/workspace/public/pm/dom.js +116 -0
  19. package/workspace/public/pm/fixture.js +979 -0
  20. package/workspace/public/pm/mobile.css +200 -0
  21. package/workspace/public/pm/mobile.js +403 -0
  22. package/workspace/public/pm/modes.js +1076 -0
  23. package/workspace/public/pm/panel-detail.js +648 -0
  24. package/workspace/public/pm/panel-initiatives.js +426 -0
  25. package/workspace/public/pm/panel-repos.js +553 -0
  26. package/workspace/public/pm/panel-tabs.js +487 -0
  27. package/workspace/public/pm/state.js +168 -0
  28. package/workspace/public/pm/tab-doc.js +279 -0
  29. package/workspace/public/pm/tab-task.js +83 -0
  30. package/workspace/public/pm/tab-term.js +560 -0
  31. package/workspace/public/pm/tokens.css +73 -0
  32. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  33. package/workspace/server.js +174 -11
  34. package/workspace/focus.test.js +0 -650
  35. package/workspace/handoff.test.js +0 -443
  36. package/workspace/profiles.test.js +0 -362
  37. package/workspace/public/app.js +0 -5560
  38. package/workspace/public/styles.css +0 -2258
  39. package/workspace/repo-scan.test.js +0 -327
package/README.md CHANGED
@@ -333,6 +333,71 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.16.1
337
+
338
+ **Two fixes on the Terminal Workspace's entry path — how a repository reaches a running workspace.**
339
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **`pmos` finds the workspace you already have open, on whatever port it is on.** The launcher used
342
+ to assume one port (7433, or whatever `PMOS_WS_PORT` said in that shell). So if another program
343
+ held 7433 and you moved the workspace to a different port, the next `pmos` — in a fresh terminal,
344
+ in a second repo — looked at 7433, found nothing, and started a *second* server. Your repo was
345
+ registered, with a workspace you were not looking at, and nothing anywhere reported an error. The
346
+ server now records the port it is serving and every `pmos` command discovers it, so a second repo
347
+ joins the workspace on screen. `PMOS_WS_PORT` still pins a port when you want one.
348
+ - **A taken port is no longer a dead end.** If something else holds 7433, `pmos` starts on the next
349
+ free port and says so, instead of failing with `port 7433 is already in use`. `pmos stop` now stops
350
+ every workspace server it can find — which is how you collapse two back into one — and `pmos
351
+ doctor` reports which servers are running and, when none is, who holds the port. A failed start
352
+ also prints only that attempt's log, so one conflict is reported once rather than three times.
353
+ - **The folder chooser is back in "add a product".** Picking a local folder had regressed to a
354
+ bare text field in the four-panel surface, so adding a repository meant typing its absolute path.
355
+ The `choose…` button opens your machine's own folder dialog again, with an in-app folder browser
356
+ standing in on machines that have none. Typing a path still works, and registration is unchanged:
357
+ a local folder is registered where it is, and nothing is imported from it.
358
+
359
+ Unchanged where it matters: **no database, no network at runtime, no telemetry.** The server still
360
+ binds `127.0.0.1` only. The file-mode kit itself is unchanged and still dependency-free — everything
361
+ above is in the optional Terminal Workspace GUI.
362
+
363
+ ## What's new in 0.16.0
364
+
365
+ **The Terminal Workspace is now a four-panel surface.** The three-column layout is retired and
366
+ replaced by one built to a PM-authored design: repositories, initiatives, initiative detail, and a
367
+ tab strip. Run `npx create-pmos@latest workspace`; the version bump means an existing install
368
+ auto-upgrades.
369
+
370
+ - **Panel 1 — repositories.** Every repo you've added, with its kind (`⎇` github / `▤` local) and
371
+ its initiative count, plus a settings & tools drawer holding the secondary modes — `agents`,
372
+ `memory`, `knowledge` for the current repository, and `focus`, `skills`, `connections`,
373
+ `scheduled` across all of them.
374
+ - **Panel 2 — initiatives, as a channel list.** Collapsible objective/KR groups over `#slug` rows,
375
+ one search box that forces the groups open and reports matches elsewhere, and an add form.
376
+ Initiatives with no anchor are filed and shown as rejected (D59) rather than quietly hidden.
377
+ - **Panel 3 — initiative detail.** The record's pills and derived footer, **task phases** whose
378
+ status is derived from the workflow ladder rather than declared, a **links** section for
379
+ references that are not repo files, and the artifact list. A record that fails to parse renders
380
+ red with the parse error — never a blank panel.
381
+ - **Panel 4 — tabs, scoped per repository.** Three kinds side by side: **term** (a real PTY running
382
+ your agent CLI untouched), **doc** (view/raw/edit/diff with save, revert and a dirty flag), and
383
+ **task** (the task spec view). New session, close, and split.
384
+ - **A phone surface.** The same four panels collapse into a mobile navigation model, so triage from
385
+ a phone is a first-class path and not a scaled-down desktop.
386
+ - **Artifact links are declarations, not integrations.** Linking an artifact to an external
387
+ destination appends `{artifact_path, kind, target}` to `pmos/state/artifact-links.jsonl`. The sync
388
+ panel is rendered, but with no connector configured its summary line says exactly that — *no
389
+ connector configured — nothing was read, and nothing will be written* — and the approval buttons
390
+ are disabled. It will not show you a difference it never read.
391
+ - **`?fixture=proto`** renders the whole surface from a bundled fixture with no repository at all,
392
+ which is also how the design comparison is run. It is never the default path.
393
+
394
+ Unchanged where it matters: **no database, no network at runtime, no telemetry.** The front end
395
+ loads only same-origin assets — fonts and the terminal emulator are served from the install, never
396
+ from a CDN — the server binds `127.0.0.1`, and the only outbound paths remain the ones you invoke
397
+ yourself (`git clone` on repo ingest, `pmos upgrade`, and the one-time `npm install` that builds
398
+ node-pty). No new runtime dependency was added. The file-mode kit itself is unchanged and still
399
+ dependency-free.
400
+
336
401
  ## What's new in 0.15.0
337
402
 
338
403
  **A major Terminal Workspace rebuild** (#242) — the PM spine, a Claude Design refresh, and a proper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@7b15792
2
- assembled: 2026-07-30
1
+ kernel-source: pmos@011b77e
2
+ assembled: 2026-07-31
3
3
  mode: vendor
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Reference
3
3
  title: "PMOS Terminal Workspace"
4
- timestamp: 2026-07-25
4
+ timestamp: 2026-07-30
5
5
  ---
6
6
 
7
7
  # PMOS Terminal Workspace
@@ -35,8 +35,13 @@ That's it. The server keeps running after you close the terminal — sessions su
35
35
  ```bash
36
36
  pmos status # is it running? which sessions?
37
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)
38
+ pmos doctor # diagnose the environment (node-pty arch, PTY spawn test, ports)
39
39
  ```
40
+ **The port is found, not assumed.** A running server records the port it serves, so `pmos` in a
41
+ second repo joins the workspace you already have open — whatever port it ended up on, and however it
42
+ was started. If another program holds the default port (7433), `pmos` starts on the next free one and
43
+ says so; `PMOS_WS_PORT=7500 pmos` pins it by hand. `pmos stop` shuts down every workspace server it
44
+ finds, which is how you collapse two back into one.
40
45
 
41
46
  <details><summary>Without the global install (running from the repo)</summary>
42
47
 
@@ -49,14 +54,34 @@ npm run test:ui # browser checks: terminal geometry, reader scroll, markdow
49
54
  ```
50
55
  </details>
51
56
 
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).
57
+ ## Layout — four panels (PM-directed design, built by `workspace-four-panel`)
58
+ The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top
59
+ bar and a 26px status bar, on the oklch token layer, in both themes:
60
+
61
+ 1. **Repositories** — every repo you've added, with its kind (`⎇` github / `▤` local) and a
62
+ `github|local · N initiatives` sub-line. `+` adds one by picking a folder (typing a path still
63
+ works; where no chooser exists, an in-app folder browser stands in). The **settings & tools**
64
+ drawer holds the secondary modes in two groups: *this repository* (`agents` · `memory` ·
65
+ `knowledge`) and *all repositories* (`focus` · `skills` · `connections` · `scheduled`). There is
66
+ deliberately no `workflow` entry — workflows are decided by the agent at runtime, not configured.
67
+ 2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
68
+ box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
69
+ initiatives are filed honestly and shown **rejected** under D59, never hidden.
70
+ 3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
71
+ footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
72
+ are derived from the workflow ladder unless the record authors them), a **links** section for
73
+ references that are not repo files, and **artifacts** with an external destination and the sync
74
+ panel. A record that fails to read renders **red with the parse error** — never a blank panel.
75
+ 4. **Tabs** — scoped per repository, two-line labels naming the initiative, three kinds: **term** (a
76
+ real PTY), **doc** (view/raw/edit/diff, save/revert, dirty flag), and **task** (the task spec).
77
+ `+ session`, close, split.
78
+
79
+ `⌘K`/`Ctrl-K` focuses search; `Esc` closes the active tab. The panel state you choose survives a
80
+ reload.
81
+
82
+ - **A phone surface.** The same four panel modules render on a phone — one shell, one renderer, no
83
+ phone-shaped copy of a list. `lib/mobile-nav.js` owns the destinations and the stack; the live
84
+ terminal host is never re-parented, so a session you navigate away from keeps running.
60
85
  - **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or
61
86
  exited, and it survives a server restart.
62
87
  - **Documents read like documents** — the identity block becomes a card, tables are tables,
@@ -66,6 +91,18 @@ the split and the rail width are draggable. The layout you choose survives a rel
66
91
  denied); markdown renders **repo-relative images inline**. A remote `![](https://…)` image is
67
92
  never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
68
93
 
94
+ **Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the
95
+ server unable to read any repository — that is how the design comparison is run
96
+ (`npm run test:parity`), and it is never the default.
97
+
98
+ ## Artifact links are declarations, not integrations
99
+ PMOS makes no outbound request and holds no credential. Linking an artifact to an external
100
+ destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to
101
+ `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector
102
+ configured its summary line says exactly that: *no connector configured — nothing was read, and
103
+ nothing will be written*, with the approval buttons disabled. A difference summary it never read
104
+ would be the watermelon this repo has a word for.
105
+
69
106
  ## The gates are real, never automatic (R5)
70
107
  `approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and
71
108
  clear the matching needs entry — only ever on your click.
@@ -83,9 +120,9 @@ count them. `pm_label` is **never** derived from a click — κ labels are the P
83
120
 
84
121
  ## Your own runs are searchable (the history lens)
85
122
  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=…`.
123
+ runs actually **did** — the thing structured run rows don't carry. `GET /api/search?q=…&ws=…`
124
+ searches it, and an agent working in a pane can ask the question itself. (The four-panel rebuild
125
+ has not yet re-surfaced this as a UI lens — the endpoint is live, the front-end entry point is not.)
89
126
 
90
127
  Two guarantees. It is scoped to **one repo** — a workspace never searches another's history
91
128
  (R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less.
@@ -14,41 +14,59 @@
14
14
  *
15
15
  * The server is started DETACHED: closing this terminal does not kill it — sessions
16
16
  * keep running until `pmos stop` (the persistent-server model, R7). Dependency-free.
17
+ *
18
+ * THE PORT IS DISCOVERED, NEVER ASSUMED (lib/server-discovery.js). Assuming 7433 is what made
19
+ * "add another repo to the running workspace" fail: after the EADDRINUSE advice moved the PM's
20
+ * workspace to 7500, a plain `pmos` in the next repo looked at 7433, saw nothing, and started a
21
+ * SECOND server there — registering the repo with a workspace nobody had on screen. Every command
22
+ * below therefore starts from `discover()`, and a port is only chosen when nothing is running.
17
23
  */
18
24
  'use strict';
19
25
  const http = require('http');
26
+ const net = require('net');
20
27
  const fs = require('fs');
21
28
  const path = require('path');
22
29
  const { spawn, spawnSync } = require('child_process');
30
+ const D = require('../lib/server-discovery');
23
31
 
24
32
  const WS = path.resolve(__dirname, '..'); // workspace/
25
33
  const STATE = path.join(WS, 'state');
26
- const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
34
+ // An explicit PMOS_WS_PORT is a PIN: this launcher then talks to that port and no other. Without
35
+ // one, the port comes from discovery — the default first, then whatever the running servers
36
+ // recorded for themselves.
37
+ const PINNED = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : null;
27
38
  // pid/log are PER-PORT: a second workspace (or a test server) on another port must never clobber
28
39
  // 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}`;
40
+ const pidFile = (port) => path.join(STATE, D.pidFileName(port));
41
+ const logFile = (port) => path.join(STATE, `server-${port}.log`);
42
+ const urlOf = (port) => `http://127.0.0.1:${port}`;
32
43
  const NO_OPEN = process.env.PMOS_NO_OPEN === '1'; // tests set this
33
44
 
34
45
  const say = (s) => console.log(s);
35
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
47
 
37
- function probe() {
48
+ // A probe is a question, not a wait: 2.5s is generous for loopback JSON on a busy machine, and the
49
+ // old 900ms was tight enough that a server under load read as "not running" — the launcher would
50
+ // then start a second one and collide with the first.
51
+ const PROBE_MS = 2500;
52
+ function probe(port) {
38
53
  return new Promise((res) => {
39
- const req = http.get(`${URL_}/api/sessions`, { timeout: 900 }, (r) => {
54
+ const req = http.get(`${urlOf(port)}/api/sessions`, { timeout: PROBE_MS }, (r) => {
40
55
  let b = ''; r.on('data', (c) => (b += c));
41
- r.on('end', () => { try { res(JSON.parse(b)); } catch { res(null); } });
56
+ r.on('end', () => {
57
+ let j = null; try { j = JSON.parse(b); } catch {}
58
+ res(D.looksLikeWorkspace(j) ? j : null); // JSON from something else is NOT our server
59
+ });
42
60
  });
43
61
  req.on('error', () => res(null));
44
62
  req.on('timeout', () => { req.destroy(); res(null); });
45
63
  });
46
64
  }
47
- function api(method, pathname, body) {
65
+ function api(port, method, pathname, body) {
48
66
  return new Promise((res) => {
49
67
  const data = body ? JSON.stringify(body) : null;
50
- const req = http.request(`${URL_}${pathname}`, {
51
- method, timeout: 3000,
68
+ const req = http.request(`${urlOf(port)}${pathname}`, {
69
+ method, timeout: 5000,
52
70
  headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
53
71
  }, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => { try { res(JSON.parse(b)); } catch { res(null); } }); });
54
72
  req.on('error', () => res(null));
@@ -58,6 +76,53 @@ function api(method, pathname, body) {
58
76
  });
59
77
  }
60
78
 
79
+ /** Every workspace server that answers, in candidate order (the default port first). */
80
+ async function discover() {
81
+ const ports = D.candidatePorts({ stateDir: STATE, pinned: PINNED });
82
+ const hits = await Promise.all(ports.map(async (port) => {
83
+ const s = await probe(port);
84
+ return s ? { port, sessions: s.sessions } : null;
85
+ }));
86
+ return hits.filter(Boolean);
87
+ }
88
+
89
+ /** Can we bind it ourselves? The only honest test of "free" — lsof answers a different question. */
90
+ function portFree(port) {
91
+ return new Promise((res) => {
92
+ const s = net.createServer();
93
+ s.once('error', () => res(false));
94
+ s.once('listening', () => s.close(() => res(true)));
95
+ s.listen(port, '127.0.0.1');
96
+ });
97
+ }
98
+
99
+ /** Who holds this port, in words the PM can act on. posix only; null when we cannot tell. */
100
+ function portOwner(port) {
101
+ if (process.platform === 'win32') return null;
102
+ try {
103
+ const r = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'cn'], { encoding: 'utf8' });
104
+ const lines = String(r.stdout || '').split('\n');
105
+ let pid = null, cmd = null;
106
+ for (const l of lines) {
107
+ if (l.startsWith('p') && pid === null) pid = Number(l.slice(1)) || null;
108
+ if (l.startsWith('c') && cmd === null) cmd = l.slice(1).trim() || null;
109
+ }
110
+ return pid ? { pid, cmd: cmd || 'unknown' } : null;
111
+ } catch { return null; }
112
+ }
113
+
114
+ // The pid of whatever is LISTENING on a port — the fallback when the pid file is missing
115
+ // (a crash, or a stale run that removed it). posix only; Windows keeps the manual message.
116
+ function listenerPid(port) {
117
+ const owner = portOwner(port);
118
+ return owner ? owner.pid : null;
119
+ }
120
+
121
+ /** Forget the records of servers that are neither answering nor alive (see stalePidFiles). */
122
+ function cleanStalePids(livePorts) {
123
+ for (const f of D.stalePidFiles(STATE, { livePorts })) { try { fs.unlinkSync(f); } catch {} }
124
+ }
125
+
61
126
  // The workspace is the REPO you are in, not the folder you happen to stand in — so `pmos`
62
127
  // from a subdirectory opens the whole repo, the same way every git tool behaves.
63
128
  function repoRootOf(dir) {
@@ -106,96 +171,184 @@ function ensureInstalled() {
106
171
  // Register the target directory and open the browser ON IT. The `focus` broadcast matters:
107
172
  // the OS may refocus an already-open tab instead of opening our URL, and without it the PM
108
173
  // would be looking at the previously-opened repo — the defect this replaces.
109
- async function openWorkspace(dir) {
174
+ async function openWorkspace(dir, port) {
110
175
  const root = repoRootOf(dir);
111
- const reg = await api('POST', '/api/workspaces', { root, name: path.basename(root) });
176
+ const reg = await api(port, 'POST', '/api/workspaces', { root, name: path.basename(root) });
112
177
  if (!reg || !reg.ok) {
113
178
  say(`✗ could not open ${root}${reg && reg.error ? ` — ${reg.error}` : ''}`);
114
- openBrowser(URL_);
179
+ openBrowser(urlOf(port));
115
180
  return 1;
116
181
  }
117
182
  const w = reg.workspace;
118
- say(`✓ workspace “${w.name}” — ${URL_}?ws=${w.id}`);
183
+ say(`✓ workspace “${w.name}” — ${urlOf(port)}?ws=${w.id}`);
119
184
  say(` ${root}`);
120
- openBrowser(`${URL_}?ws=${w.id}`);
185
+ openBrowser(`${urlOf(port)}?ws=${w.id}`);
121
186
  return 0;
122
187
  }
123
188
 
189
+ /**
190
+ * Where to start a server, when none is running. Two failure shapes are separated here because the
191
+ * PM's next action differs:
192
+ * - the PINNED port is taken → we do not silently move; a pin was an instruction. Say who holds it.
193
+ * - the DEFAULT port is taken by something that is not a workspace → roll forward and SAY SO. The
194
+ * old behaviour was to spawn into it anyway, print the child's EADDRINUSE, and stop — leaving
195
+ * the PM to pick a port by hand and, having picked one, to lose the next repo to 7433.
196
+ */
197
+ async function choosePort() {
198
+ const want = PINNED || D.DEFAULT_PORT;
199
+ if (await portFree(want)) return { port: want };
200
+ if (PINNED) return { port: null, blocked: want, owner: portOwner(want) };
201
+ // portFree is async and nextFreePort is pure, so probe the span first and let it pick.
202
+ const span = [];
203
+ for (let p = want; p < want + D.FALLBACK_SPAN; p++) span.push([p, await portFree(p)]);
204
+ const free = new Map(span);
205
+ const port = D.nextFreePort(want, (p) => free.get(p) === true);
206
+ return port ? { port, movedFrom: want, owner: portOwner(want) } : { port: null, blocked: want, owner: portOwner(want) };
207
+ }
208
+
124
209
  async function up(dir) {
125
- if (await probe()) return openWorkspace(dir);
210
+ // 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
211
+ let live = await discover();
212
+ if (live.length) {
213
+ const port = live[0].port;
214
+ if (live.length > 1) {
215
+ // Two servers of one install is the split this change exists to prevent, and it can still be
216
+ // arrived at by hand (`npm start` twice on different ports). There is no reliable way to pick
217
+ // the one the PM is LOOKING at — workspaces.json and the session store are shared by both, so
218
+ // every per-repo signal reads the same on each — so this does not guess quietly: it uses
219
+ // candidate order, names every server it found, and says how to collapse them back to one.
220
+ say(`note: ${live.length} workspace servers are running (${live.map((l) => l.port).join(', ')}) — using ${port}.`);
221
+ say(' They share this install\'s state but not each other\'s live terminals. `pmos stop` shuts them all down.');
222
+ }
223
+ return openWorkspace(dir, port);
224
+ }
126
225
  if (!ensureInstalled()) return 1;
127
226
  fs.mkdirSync(STATE, { recursive: true });
227
+ cleanStalePids([]);
228
+
229
+ const choice = await choosePort();
230
+ if (!choice.port) {
231
+ const who = choice.owner ? `${choice.owner.cmd} (pid ${choice.owner.pid})` : 'another program';
232
+ say(`✗ port ${choice.blocked} is held by ${who}, and it is not a workspace server.`);
233
+ say(PINNED
234
+ ? ` PMOS_WS_PORT pinned this launcher to ${choice.blocked}. Stop that process, or pin a free port instead.`
235
+ : ` Stop that process, or start elsewhere: PMOS_WS_PORT=7500 pmos`);
236
+ return 1;
237
+ }
238
+ if (choice.movedFrom && choice.port !== choice.movedFrom) {
239
+ const who = choice.owner ? `${choice.owner.cmd} (pid ${choice.owner.pid})` : 'another program';
240
+ say(`note: port ${choice.movedFrom} is in use by ${who} — starting this workspace on ${choice.port} instead.`);
241
+ }
242
+ const port = choice.port;
243
+
244
+ const LOGFILE = logFile(port);
245
+ // Only THIS attempt's log lines may be shown below. The log is append-only, so reading its tail
246
+ // reprinted failures from previous launches — the PM saw the same EADDRINUSE three times and
247
+ // reasonably read it as three live conflicts.
248
+ let logFrom = 0;
249
+ try { logFrom = fs.statSync(LOGFILE).size; } catch {}
128
250
  const log = fs.openSync(LOGFILE, 'a');
129
251
  // Tell the server which repo is HOME. An installed/vendored workspace does not live inside the
130
252
  // repo it manages (it's under node_modules or an npx bundle), so the server cannot derive home
131
253
  // from its own location — without this it would register the PACKAGE folder as "Home" (the
132
254
  // tenant defect). Pass the repo we're opening; a caller-set PMOS_SELF_ROOT still wins.
133
- const env = { ...process.env, PMOS_SELF_ROOT: process.env.PMOS_SELF_ROOT || repoRootOf(dir) };
255
+ // PMOS_WS_PORT is passed EXPLICITLY: the port was chosen here, and inheriting the shell's idea
256
+ // of it would put the child somewhere other than where this launcher then looks.
257
+ const env = {
258
+ ...process.env,
259
+ PMOS_SELF_ROOT: process.env.PMOS_SELF_ROOT || repoRootOf(dir),
260
+ PMOS_WS_PORT: String(port),
261
+ };
134
262
  const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
135
263
  cwd: WS, detached: true, stdio: ['ignore', log, log], env,
136
264
  });
137
265
  child.unref();
138
- try { fs.writeFileSync(PIDFILE, String(child.pid)); } catch {}
266
+ try { fs.writeFileSync(pidFile(port), String(child.pid)); } catch {}
139
267
  process.stdout.write('starting the workspace ');
140
268
  for (let i = 0; i < 40; i++) { // up to ~10s
141
- if (await probe()) {
269
+ if (await probe(port)) {
142
270
  say('');
143
271
  say(' sessions keep running after you close this terminal · `pmos stop` shuts it down');
144
- return openWorkspace(dir);
272
+ return openWorkspace(dir, port);
145
273
  }
146
274
  process.stdout.write('.');
147
275
  await sleep(250);
148
276
  }
277
+ // Last chance before reporting failure: another launcher may have won the race and be serving
278
+ // this repo already, on a port it recorded while we were waiting.
279
+ live = await discover();
280
+ if (live.length) { say(''); return openWorkspace(dir, live[0].port); }
281
+
149
282
  say(`\n✗ the server did not come up. Its log says:`);
150
- try { say(' ' + fs.readFileSync(LOGFILE, 'utf8').trim().split('\n').slice(-6).join('\n ')); } catch {}
283
+ try {
284
+ const fd = fs.openSync(LOGFILE, 'r');
285
+ const size = fs.statSync(LOGFILE).size;
286
+ const buf = Buffer.alloc(Math.max(0, size - logFrom));
287
+ if (buf.length) fs.readSync(fd, buf, 0, buf.length, logFrom);
288
+ fs.closeSync(fd);
289
+ const fresh = buf.toString('utf8').trim();
290
+ say(fresh ? ' ' + fresh.split('\n').slice(-6).join('\n ') : ' (it wrote nothing at all)');
291
+ } catch {}
151
292
  say(' Run `pmos doctor` for a diagnosis.');
152
293
  return 1;
153
294
  }
154
295
 
155
296
  async function status() {
156
- const s = await probe();
157
- if (!s) { say(`○ not running (start it with: pmos)`); return 1; }
158
- const wl = (await api('GET', '/api/workspaces')) || { workspaces: [] };
159
- const live = s.sessions.filter((x) => x.state !== 'exited').length;
160
- say(`✓ running — ${URL_}`);
161
- say(` ${wl.workspaces.length} workspace(s): ${wl.workspaces.map((w) => w.name).join(' · ') || 'none'}`);
162
- say(` ${s.sessions.length} session(s), ${live} live: ${s.sessions.map((x) => `${x.name}[${x.state}]`).join(' · ') || 'none'}`);
297
+ const live = await discover();
298
+ if (!live.length) { say(`○ not running (start it with: pmos)`); return 1; }
299
+ for (const s of live) {
300
+ const wl = (await api(s.port, 'GET', '/api/workspaces')) || { workspaces: [] };
301
+ const alive = s.sessions.filter((x) => x.state !== 'exited').length;
302
+ say(`✓ running — ${urlOf(s.port)}`);
303
+ say(` ${wl.workspaces.length} workspace(s): ${wl.workspaces.map((w) => w.name).join(' · ') || 'none'}`);
304
+ say(` ${s.sessions.length} session(s), ${alive} live: ${s.sessions.map((x) => `${x.name}[${x.state}]`).join(' · ') || 'none'}`);
305
+ }
306
+ if (live.length > 1) say(`note: ${live.length} servers are running — a repo opened by \`pmos\` goes to ${live[0].port}.`);
163
307
  return 0;
164
308
  }
165
309
 
166
310
  async function ls() {
167
- if (!(await probe())) { say('○ not running (start it with: pmos)'); return 1; }
168
- const wl = (await api('GET', '/api/workspaces')) || { workspaces: [] };
169
- if (!wl.workspaces.length) { say('no workspaces registered.'); return 0; }
170
- for (const w of wl.workspaces) {
171
- say(`${w.home ? '★' : '·'} ${w.name} ${w.sessions} session(s) [${w.shape}]`);
172
- say(` ${w.root}`);
311
+ const live = await discover();
312
+ if (!live.length) { say('○ not running (start it with: pmos)'); return 1; }
313
+ let any = false;
314
+ for (const s of live) {
315
+ const wl = (await api(s.port, 'GET', '/api/workspaces')) || { workspaces: [] };
316
+ if (live.length > 1) say(`— ${urlOf(s.port)}`);
317
+ for (const w of wl.workspaces) {
318
+ any = true;
319
+ say(`${w.home ? '★' : '·'} ${w.name} ${w.sessions} session(s) [${w.shape}]`);
320
+ say(` ${w.root}`);
321
+ }
173
322
  }
323
+ if (!any) say('no workspaces registered.');
174
324
  return 0;
175
325
  }
176
326
 
177
- // The pid of whatever is LISTENING on our port — the fallback when the pid file is missing
178
- // (a crash, or a stale run that removed it). posix only; Windows keeps the manual message.
179
- function listenerPid() {
180
- if (process.platform === 'win32') return null;
181
- try {
182
- const r = spawnSync('lsof', ['-ti', `tcp:${PORT}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
183
- const pid = Number(String(r.stdout || '').split('\n')[0].trim());
184
- return Number.isInteger(pid) && pid > 0 ? pid : null;
185
- } catch { return null; }
327
+ /** Stop ONE server, by pid file if it left one, otherwise by whoever owns the port. */
328
+ async function stopOne(port) {
329
+ let pid = null;
330
+ try { pid = Number(fs.readFileSync(pidFile(port), 'utf8').trim()) || null; } catch {}
331
+ if (!pid) pid = listenerPid(port); // pid file lost — find the server by the port it owns
332
+ if (!pid) { say(`✗ could not locate the server on port ${port} (no pid file, and nothing I can see owns it). Stop it manually.`); return 1; }
333
+ try { process.kill(pid); } catch (e) { say(`✗ could not signal pid ${pid}: ${e.message}`); return 1; }
334
+ for (let i = 0; i < 20; i++) {
335
+ if (!(await probe(port))) { say(`✓ stopped — ${urlOf(port)}`); try { fs.unlinkSync(pidFile(port)); } catch {} return 0; }
336
+ await sleep(250);
337
+ }
338
+ say(`✗ port ${port} still responding after 5s — kill it manually (pid ${pid}).`);
339
+ return 1;
186
340
  }
187
341
 
188
342
  async function stop() {
189
- if (!(await probe())) { say('○ not running.'); try { fs.unlinkSync(PIDFILE); } catch {} return 0; }
190
- let pid = null;
191
- try { pid = Number(fs.readFileSync(PIDFILE, 'utf8').trim()); } catch {}
192
- if (!pid) pid = listenerPid(); // pid file lost — find the server by the port it owns
193
- 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; }
343
+ const live = await discover();
344
+ if (!live.length) { say('○ not running.'); cleanStalePids([]); return 0; }
194
345
  // sessions are tmux-backed: stopping the server leaves them running; the next `pmos` re-attaches.
195
346
  say('stopping the workspace — sessions keep running in tmux and re-attach on the next launch…');
196
- try { process.kill(pid); } catch (e) { say(`✗ could not signal pid ${pid}: ${e.message}`); return 1; }
197
- for (let i = 0; i < 20; i++) { if (!(await probe())) { say('✓ stopped.'); try { fs.unlinkSync(PIDFILE); } catch {} return 0; } await sleep(250); }
198
- say('✗ still responding after 5s — kill it manually.'); return 1;
347
+ // ALL of them, not just the first: a stranded second server is precisely what this command is
348
+ // reached for, and stopping one of two leaves the PM in the state they came here to escape.
349
+ let code = 0;
350
+ for (const s of live) code = (await stopOne(s.port)) || code;
351
+ return code;
199
352
  }
200
353
 
201
354
  function doctor() {