create-pmos 0.232.0 → 0.233.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.232.0",
3
+ "version": "0.233.0",
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. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `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@ac35488
1
+ kernel-source: pmos@c113f92
2
2
  assembled: 2026-10-14
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-14T08:45:03Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-14T08:45:03Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-14T08:45:03Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -57,9 +57,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
57
57
  // old 900ms was tight enough that a server under load read as "not running" — the launcher would
58
58
  // then start a second one and collide with the first.
59
59
  const PROBE_MS = 2500;
60
- function probe(port) {
60
+ function probe(port, ms = PROBE_MS) {
61
61
  return new Promise((res) => {
62
- const req = http.get(`${urlOf(port)}/api/sessions`, { timeout: PROBE_MS }, (r) => {
62
+ const req = http.get(`${urlOf(port)}/api/sessions`, { timeout: ms }, (r) => {
63
63
  let b = ''; r.on('data', (c) => (b += c));
64
64
  r.on('end', () => {
65
65
  let j = null; try { j = JSON.parse(b); } catch {}
@@ -94,6 +94,31 @@ async function discover() {
94
94
  return hits.filter(Boolean);
95
95
  }
96
96
 
97
+ /**
98
+ * Our own servers that are running but did not answer the probe (lib/server-discovery.js busyServers).
99
+ * Pinned launches only look at the pin, the same as discover().
100
+ */
101
+ async function busy(livePorts = []) {
102
+ const held = [];
103
+ for (const p of D.candidatePorts({ stateDir: STATE, pinned: PINNED })) if (!livePorts.includes(p) && !(await portFree(p))) held.push(p);
104
+ return D.busyServers(STATE, { livePorts, heldPorts: held });
105
+ }
106
+
107
+ // How long to wait for a busy server before giving up on it. A server that is only slow answers well
108
+ // inside this; one that is stuck does not, and starting a second server beside it does not help.
109
+ const BUSY_WAIT_MS = 60000;
110
+ async function waitForBusy(servers) {
111
+ process.stdout.write(`the workspace on port ${servers[0].port} is busy; waiting for it to answer `);
112
+ const until = Date.now() + BUSY_WAIT_MS;
113
+ while (Date.now() < until) {
114
+ for (const b of servers) if (await probe(b.port, 10000)) { say(''); return b.port; }
115
+ process.stdout.write('.');
116
+ await sleep(1000);
117
+ }
118
+ say('');
119
+ return null;
120
+ }
121
+
97
122
  /** Can we bind it ourselves? The only honest test of "free" — lsof answers a different question. */
98
123
  function portFree(port) {
99
124
  return new Promise((res) => {
@@ -257,7 +282,7 @@ async function servesTailnet(port) {
257
282
 
258
283
  // `remote` starts a NEW server in tailnet mode (pmos phone); a server that is already running is reused as it is.
259
284
  // `open: false` registers the repo without opening the desktop browser.
260
- async function up(dir, { remote = false, open = true } = {}) {
285
+ async function up(dir, { remote = false, open = true, waited = false } = {}) {
261
286
  // 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
262
287
  let live = await discover();
263
288
  // A server that predates this install is running OLD code under NEW files (or the reverse). Reusing
@@ -289,6 +314,15 @@ async function up(dir, { remote = false, open = true } = {}) {
289
314
  }
290
315
  return openWorkspace(dir, port, { open });
291
316
  }
317
+ // 2. Is one of OUR servers running but too busy to answer? Then wait for it. Starting a second
318
+ // server on the next port is what left a Windows PM with two servers and neither loading.
319
+ const slow = waited ? [] : await busy();
320
+ if (slow.length) {
321
+ if (await waitForBusy(slow)) return up(dir, { remote, open, waited: true }); // re-run: it may be an older version
322
+ say(`✗ the workspace server on port ${slow[0].port} (pid ${slow[0].pid}) is running but has not answered for a minute.`);
323
+ say(' Run `pmos stop`, then `pmos` to restart it. If it happens again, run `pmos doctor` and send its output.');
324
+ return 1;
325
+ }
292
326
  if (!ensureInstalled()) return 1;
293
327
  fs.mkdirSync(STATE, { recursive: true });
294
328
  cleanStalePids([]);
@@ -337,7 +371,10 @@ async function up(dir, { remote = false, open = true } = {}) {
337
371
  child.unref();
338
372
  try { fs.writeFileSync(pidFile(port), String(child.pid)); } catch {}
339
373
  process.stdout.write('starting the workspace ');
340
- for (let i = 0; i < 40 && !exited; i++) { // up to ~10s
374
+ // Up to ~60s while the server process is alive. A first start on Windows (antivirus scanning every
375
+ // new file) can take longer than 10s, and giving up early left a server starting with nobody waiting.
376
+ for (let i = 0; i < 240 && !exited; i++) {
377
+ if (i === 40) process.stdout.write('\n still starting — a first start can take up to a minute ');
341
378
  if (await probe(port)) {
342
379
  say('');
343
380
  say(' sessions keep running after you close this terminal · `pmos stop` shuts it down');
@@ -404,7 +441,8 @@ async function stopOne(port) {
404
441
  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; }
405
442
  try { process.kill(pid); } catch (e) { say(`✗ could not signal pid ${pid}: ${e.message}`); return 1; }
406
443
  for (let i = 0; i < 20; i++) {
407
- if (!(await probe(port))) { say(`✓ stopped — ${urlOf(port)}`); try { fs.unlinkSync(pidFile(port)); } catch {} return 0; }
444
+ // Gone means the process ended or the port is free. "Not answering" is not enough: a busy server does not answer either.
445
+ if (!D.pidAlive(pid) || await portFree(port)) { say(`✓ stopped — ${urlOf(port)}`); try { fs.unlinkSync(pidFile(port)); } catch {} return 0; }
408
446
  await sleep(250);
409
447
  }
410
448
  say(`✗ port ${port} still responding after 5s — kill it manually (pid ${pid}).`);
@@ -412,7 +450,9 @@ async function stopOne(port) {
412
450
  }
413
451
 
414
452
  async function stop() {
415
- const live = await discover();
453
+ // A busy server does not answer the probe but is still ours, and still holds its port. Stop it too.
454
+ const answering = await discover();
455
+ const live = [...answering, ...await busy(answering.map((l) => l.port))];
416
456
  if (!live.length) { say('○ not running.'); cleanStalePids([]); return 0; }
417
457
  // sessions are tmux-backed: stopping the server leaves them running; the next `pmos` re-attaches.
418
458
  say('stopping the workspace — sessions keep running in tmux and re-attach on the next launch…');
@@ -167,7 +167,11 @@ function binaryArch(file) {
167
167
  } else {
168
168
  const want = pinned || D.DEFAULT_PORT;
169
169
  if (await free(want)) ok(`no server running; port ${want} is free (start it with: pmos)`);
170
- else {
170
+ else if (D.busyServers(STATE, { heldPorts: [want] }).length) {
171
+ const b = D.busyServers(STATE, { heldPorts: [want] })[0];
172
+ bad(`the workspace server on port ${want} (pid ${b.pid}) is running but did not answer within 2s — it is busy`,
173
+ 'wait a minute and run `pmos` again; if it still does not answer, run `pmos stop`, then `pmos`');
174
+ } else {
171
175
  const who = owner(want) || 'another program';
172
176
  bad(`no workspace server is answering, but port ${want} is held by ${who}`,
173
177
  pinned ? `stop that process, or pin a free port: PMOS_WS_PORT=7500 pmos`
@@ -102,6 +102,21 @@ function stalePidFiles(stateDir, { livePorts = [], alive = pidAlive } = {}) {
102
102
  .map((r) => r.file);
103
103
  }
104
104
 
105
+ /**
106
+ * The workspace servers that are running but did not answer: a record whose port is not among the
107
+ * live ones, whose port is still held, and whose pid is alive. A busy server (a long synchronous job,
108
+ * or a slow first start on Windows) looks exactly like this. Reading it as "something else holds the
109
+ * port" is how `pmos` started a second server beside a first one that was only slow. `heldPorts` is
110
+ * injected (the caller owns the socket), so this stays pure.
111
+ */
112
+ function busyServers(stateDir, { livePorts = [], heldPorts = [], alive = pidAlive } = {}) {
113
+ const live = new Set(livePorts.map(Number));
114
+ const held = new Set(heldPorts.map(Number));
115
+ return pidRecords(stateDir)
116
+ .filter((r) => !live.has(r.port) && held.has(r.port) && alive(r.pid))
117
+ .map((r) => ({ port: r.port, pid: r.pid }));
118
+ }
119
+
105
120
  /**
106
121
  * The first port at or after `start` that `isFree` accepts, within the bounded span. `isFree` is
107
122
  * injected (the caller owns the socket), so this stays pure and the span is assertable.
@@ -123,5 +138,5 @@ function looksLikeWorkspace(body) {
123
138
 
124
139
  module.exports = {
125
140
  DEFAULT_PORT, FALLBACK_SPAN, PID_RE,
126
- pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, nextFreePort, looksLikeWorkspace,
141
+ pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, busyServers, nextFreePort, looksLikeWorkspace,
127
142
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.232.0",
3
+ "version": "0.233.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.232.0",
9
+ "version": "0.233.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.232.0",
3
+ "version": "0.233.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "one-line-setup: on Windows the background server hides the console window of every command it runs (lib/win-hide.js).",
3
- "modules": ["win-hide.test.js"],
4
- "seconds": { "win-hide.test.js": 1 }
2
+ "_comment": "one-line-setup: on Windows the background server hides the console window of every command it runs (lib/win-hide.js); `pmos` waits for its own busy server instead of starting a second one (busy-server.test.js).",
3
+ "modules": ["win-hide.test.js", "busy-server.test.js"],
4
+ "seconds": { "win-hide.test.js": 1, "busy-server.test.js": 12 }
5
5
  }