create-pmos 0.16.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.
- package/README.md +27 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +6 -1
- package/workspace/bin/pmos.js +204 -51
- package/workspace/doctor.js +54 -0
- package/workspace/lib/server-discovery.js +127 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +28 -0
- package/workspace/public/pm/panel-repos.js +94 -1
- package/workspace/public/pm/state.js +4 -0
- package/workspace/server.js +16 -3
package/README.md
CHANGED
|
@@ -333,6 +333,33 @@ 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
|
+
|
|
336
363
|
## What's new in 0.16.0
|
|
337
364
|
|
|
338
365
|
**The Terminal Workspace is now a four-panel surface.** The three-column layout is retired and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.16.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-07-
|
|
1
|
+
kernel-source: pmos@011b77e
|
|
2
|
+
assembled: 2026-07-31
|
|
3
3
|
mode: vendor
|
package/workspace/README.md
CHANGED
|
@@ -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
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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
|
-
|
|
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(`${
|
|
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', () => {
|
|
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(`${
|
|
51
|
-
method, timeout:
|
|
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(
|
|
179
|
+
openBrowser(urlOf(port));
|
|
115
180
|
return 1;
|
|
116
181
|
}
|
|
117
182
|
const w = reg.workspace;
|
|
118
|
-
say(`✓ workspace “${w.name}” — ${
|
|
183
|
+
say(`✓ workspace “${w.name}” — ${urlOf(port)}?ws=${w.id}`);
|
|
119
184
|
say(` ${root}`);
|
|
120
|
-
openBrowser(`${
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
157
|
-
if (!
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
for (const
|
|
171
|
-
|
|
172
|
-
say(
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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() {
|
package/workspace/doctor.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
'use strict';
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const net = require('net');
|
|
9
11
|
const { spawnSync } = require('child_process');
|
|
10
12
|
|
|
11
13
|
const checks = [];
|
|
@@ -116,6 +118,58 @@ function binaryArch(file) {
|
|
|
116
118
|
console.log(' optional fix: install tmux (macOS `brew install tmux`; Linux `apt install tmux`) for restart-proof sessions.');
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// 6. ports — who is running, and who holds the door. `pmos` points here after a failed start,
|
|
122
|
+
// and until now the doctor had nothing to say about the one thing that had just failed.
|
|
123
|
+
// Reports every workspace server it can find (the launcher discovers them the same way) and,
|
|
124
|
+
// when none is running, whether the port it would use is free and who holds it if not.
|
|
125
|
+
{
|
|
126
|
+
const D = require('./lib/server-discovery');
|
|
127
|
+
const STATE = path.join(__dirname, 'state');
|
|
128
|
+
const pinned = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : null;
|
|
129
|
+
const ports = D.candidatePorts({ stateDir: STATE, pinned });
|
|
130
|
+
const probe = (port) => new Promise((res) => {
|
|
131
|
+
const req = http.get(`http://127.0.0.1:${port}/api/sessions`, { timeout: 2000 }, (r) => {
|
|
132
|
+
let b = ''; r.on('data', (c) => (b += c));
|
|
133
|
+
r.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} res(D.looksLikeWorkspace(j) ? j : null); });
|
|
134
|
+
});
|
|
135
|
+
req.on('error', () => res(null));
|
|
136
|
+
req.on('timeout', () => { req.destroy(); res(null); });
|
|
137
|
+
});
|
|
138
|
+
const free = (port) => new Promise((res) => {
|
|
139
|
+
const s = net.createServer();
|
|
140
|
+
s.once('error', () => res(false));
|
|
141
|
+
s.once('listening', () => s.close(() => res(true)));
|
|
142
|
+
s.listen(port, '127.0.0.1');
|
|
143
|
+
});
|
|
144
|
+
const owner = (port) => {
|
|
145
|
+
if (process.platform === 'win32') return null;
|
|
146
|
+
const r = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'cn'], { encoding: 'utf8' });
|
|
147
|
+
const lines = String(r.stdout || '').split('\n');
|
|
148
|
+
let pid = null, cmd = null;
|
|
149
|
+
for (const l of lines) {
|
|
150
|
+
if (l.startsWith('p') && pid === null) pid = Number(l.slice(1)) || null;
|
|
151
|
+
if (l.startsWith('c') && cmd === null) cmd = l.slice(1).trim() || null;
|
|
152
|
+
}
|
|
153
|
+
return pid ? `${cmd || 'unknown'} (pid ${pid})` : null;
|
|
154
|
+
};
|
|
155
|
+
const live = [];
|
|
156
|
+
for (const p of ports) if (await probe(p)) live.push(p);
|
|
157
|
+
if (live.length === 1) ok(`workspace server running on port ${live[0]} — http://127.0.0.1:${live[0]}`);
|
|
158
|
+
else if (live.length > 1) {
|
|
159
|
+
console.log(`• ${live.length} workspace servers are running (ports ${live.join(', ')}).`);
|
|
160
|
+
console.log(' Each hosts its OWN repos, and a browser tab only sees one of them. `pmos stop` shuts them all down.');
|
|
161
|
+
} else {
|
|
162
|
+
const want = pinned || D.DEFAULT_PORT;
|
|
163
|
+
if (await free(want)) ok(`no server running; port ${want} is free (start it with: pmos)`);
|
|
164
|
+
else {
|
|
165
|
+
const who = owner(want) || 'another program';
|
|
166
|
+
bad(`no workspace server is answering, but port ${want} is held by ${who}`,
|
|
167
|
+
pinned ? `stop that process, or pin a free port: PMOS_WS_PORT=7500 pmos`
|
|
168
|
+
: 'run `pmos` — it starts on the next free port when this one is taken; or stop that process');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
const fails = checks.filter((c) => !c).length;
|
|
120
174
|
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'}`);
|
|
121
175
|
process.exit(fails ? 1 : 0);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/server-discovery.js — WHICH workspace server is running, and on WHICH port.
|
|
3
|
+
*
|
|
4
|
+
* The launcher used to answer that question with a constant: port 7433, or whatever
|
|
5
|
+
* `PMOS_WS_PORT` said in THIS shell. That constant is why a second repo could not be added to a
|
|
6
|
+
* running workspace. The sequence the PM hit, end to end:
|
|
7
|
+
*
|
|
8
|
+
* 1. something already held 7433 (a wedged server, a crashed one whose socket lingered, or an
|
|
9
|
+
* unrelated program), so the server exited with EADDRINUSE and printed the advice
|
|
10
|
+
* `PMOS_WS_PORT=7500 npm start`;
|
|
11
|
+
* 2. the PM followed it, and the workspace came up on 7500 — the browser tab is on 7500;
|
|
12
|
+
* 3. `pmos` in the other repo, in a fresh shell with no `PMOS_WS_PORT`, looked at 7433 only,
|
|
13
|
+
* found nothing, and started a SECOND server there. The repo was registered — with the
|
|
14
|
+
* server nobody was looking at.
|
|
15
|
+
*
|
|
16
|
+
* Nothing in that sequence errors. That is the whole defect: two servers, each honest, and a repo
|
|
17
|
+
* that "never added" because it landed in the one that isn't on screen.
|
|
18
|
+
*
|
|
19
|
+
* So the port is DISCOVERED, never assumed. The candidate list is built from state the running
|
|
20
|
+
* server itself writes (`state/server-<port>.pid`, written on listen and removed on shutdown —
|
|
21
|
+
* server.js), which makes a server discoverable however it was started: `pmos`, `npm start`, or a
|
|
22
|
+
* hand-typed `PMOS_WS_PORT=7500 node server.js`.
|
|
23
|
+
*
|
|
24
|
+
* An explicit `PMOS_WS_PORT` is treated as a PIN, not a hint: it narrows the candidates to that one
|
|
25
|
+
* port and never widens them. The test suite depends on that isolation — a suite that could attach
|
|
26
|
+
* to the PM's real workspace would drive their live sessions.
|
|
27
|
+
*
|
|
28
|
+
* Everything here is pure, or reads the filesystem and nothing else: no sockets, no child
|
|
29
|
+
* processes. The I/O lives in the caller (bin/pmos.js, doctor.js), so the whole policy is testable
|
|
30
|
+
* in-process without binding a port.
|
|
31
|
+
*/
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
const DEFAULT_PORT = 7433;
|
|
36
|
+
// How far the launcher rolls forward when the default port is taken by something that is not a
|
|
37
|
+
// workspace server. Bounded on purpose: "start somewhere, anywhere" would scatter servers across
|
|
38
|
+
// the ephemeral range and make discovery a scan.
|
|
39
|
+
const FALLBACK_SPAN = 12;
|
|
40
|
+
|
|
41
|
+
// state/server-<port>.pid — the launcher AND the server agree on this name; it is the only place a
|
|
42
|
+
// port is recorded, so it is the only shape parsed here.
|
|
43
|
+
const PID_RE = /^server-(\d{2,5})\.pid$/;
|
|
44
|
+
|
|
45
|
+
const pidFileName = (port) => `server-${Number(port)}.pid`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `state/server-<port>.pid` on disk, most recently written first. A record is returned even
|
|
49
|
+
* when its pid is unreadable — a file whose contents are gone still names a port worth probing,
|
|
50
|
+
* and treating it as absent is how a running server becomes invisible.
|
|
51
|
+
*/
|
|
52
|
+
function pidRecords(stateDir) {
|
|
53
|
+
let names = [];
|
|
54
|
+
try { names = fs.readdirSync(stateDir); } catch { return []; }
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const m = PID_RE.exec(name);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
const file = path.join(stateDir, name);
|
|
60
|
+
let pid = null, mtime = 0;
|
|
61
|
+
try { pid = Number(String(fs.readFileSync(file, 'utf8')).trim()) || null; } catch {}
|
|
62
|
+
try { mtime = fs.statSync(file).mtimeMs; } catch {}
|
|
63
|
+
out.push({ port: Number(m[1]), pid, file, mtime });
|
|
64
|
+
}
|
|
65
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The ports to probe, in order. With a pinned port that list is exactly one long — see the header:
|
|
71
|
+
* a pin is an instruction, and widening it would let a test (or a second workspace deliberately
|
|
72
|
+
* kept apart) attach to a server it was told not to touch.
|
|
73
|
+
*/
|
|
74
|
+
function candidatePorts({ stateDir, pinned = null, defaultPort = DEFAULT_PORT } = {}) {
|
|
75
|
+
const pin = Number(pinned);
|
|
76
|
+
if (Number.isInteger(pin) && pin > 0) return [pin];
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const out = [];
|
|
79
|
+
const push = (p) => { if (Number.isInteger(p) && p > 0 && !seen.has(p)) { seen.add(p); out.push(p); } };
|
|
80
|
+
push(Number(defaultPort) || DEFAULT_PORT);
|
|
81
|
+
for (const r of pidRecords(stateDir)) push(r.port);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Is this pid still around? EPERM means yes-but-not-ours, which is still alive. */
|
|
86
|
+
function pidAlive(pid, kill) {
|
|
87
|
+
const k = kill || ((p, sig) => process.kill(p, sig));
|
|
88
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
89
|
+
try { k(pid, 0); return true; } catch (e) { return !!e && e.code === 'EPERM'; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which pid files describe a server that is gone. Deliberately conservative: a file is stale only
|
|
94
|
+
* when its port does NOT answer AND its pid is NOT alive. A server that is mid-start — listening
|
|
95
|
+
* socket not yet open, process very much alive — must survive this, or one launcher would delete
|
|
96
|
+
* another's record and make it undiscoverable a second later.
|
|
97
|
+
*/
|
|
98
|
+
function stalePidFiles(stateDir, { livePorts = [], alive = pidAlive } = {}) {
|
|
99
|
+
const live = new Set(livePorts.map(Number));
|
|
100
|
+
return pidRecords(stateDir)
|
|
101
|
+
.filter((r) => !live.has(r.port) && !alive(r.pid))
|
|
102
|
+
.map((r) => r.file);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The first port at or after `start` that `isFree` accepts, within the bounded span. `isFree` is
|
|
107
|
+
* injected (the caller owns the socket), so this stays pure and the span is assertable.
|
|
108
|
+
*/
|
|
109
|
+
function nextFreePort(start, isFree, span = FALLBACK_SPAN) {
|
|
110
|
+
const from = Number(start) || DEFAULT_PORT;
|
|
111
|
+
for (let p = from; p < from + span; p++) if (isFree(p)) return p;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does this JSON body come from a workspace server? `/api/sessions` answers `{sessions:[…]}`, and
|
|
117
|
+
* requiring the array is what keeps an unrelated program that happens to serve JSON on 7433 from
|
|
118
|
+
* being adopted as "the workspace" — the launcher would then register the PM's repo into it.
|
|
119
|
+
*/
|
|
120
|
+
function looksLikeWorkspace(body) {
|
|
121
|
+
return !!body && typeof body === 'object' && Array.isArray(body.sessions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
DEFAULT_PORT, FALLBACK_SPAN, PID_RE,
|
|
126
|
+
pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, nextFreePort, looksLikeWorkspace,
|
|
127
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.16.
|
|
9
|
+
"version": "0.16.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -774,6 +774,34 @@ export async function addRepo({ source, kind }) {
|
|
|
774
774
|
};
|
|
775
775
|
}
|
|
776
776
|
|
|
777
|
+
/**
|
|
778
|
+
* panel-repos.js — `chooseFolder(start)`. The machine's OWN folder dialog, opened by the server.
|
|
779
|
+
*
|
|
780
|
+
* A browser cannot hand back an absolute path — its directory picker yields a name and a handle,
|
|
781
|
+
* never a path — so the add-a-product form could only ever offer a text field, and the PM had to
|
|
782
|
+
* know and type the path of the repo they were looking at. The server runs on the PM's machine and
|
|
783
|
+
* binds 127.0.0.1 (D62), so it opens the native chooser and returns the chosen path; this function
|
|
784
|
+
* only asks. Registration still goes through `addRepo()` → POST /api/workspaces, so nothing here
|
|
785
|
+
* bypasses a guard: the dialog produces a string, and the PM confirms it like any other.
|
|
786
|
+
*
|
|
787
|
+
* `{cancelled:true}` and `{unsupported:true}` are ANSWERS, not failures — a headless Linux box has
|
|
788
|
+
* no chooser at all, which is what `listFolder()` below stands in for.
|
|
789
|
+
*/
|
|
790
|
+
export async function chooseFolder(start) {
|
|
791
|
+
if (FIXTURE) return { ok: false, unsupported: true };
|
|
792
|
+
return postJson('/api/pick-folder', { start: start || '' });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* panel-repos.js — `listFolder(dir)`. The in-app folder browser's data source, for when there is
|
|
797
|
+
* no native chooser (and for anyone who prefers it). The server returns directory NAMES only —
|
|
798
|
+
* never a file, never a byte of content — and flags which ones are git repositories.
|
|
799
|
+
*/
|
|
800
|
+
export async function listFolder(dir) {
|
|
801
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode has no filesystem' };
|
|
802
|
+
return getJson('/api/fs/list', dir ? { path: dir } : null);
|
|
803
|
+
}
|
|
804
|
+
|
|
777
805
|
/**
|
|
778
806
|
* panel-tabs.js — `startSession(slug, sess)`. Deliberately NOT a spawn.
|
|
779
807
|
*
|
|
@@ -299,6 +299,93 @@ function field(key, value) {
|
|
|
299
299
|
});
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/* ── choosing the folder, instead of typing its path ─────────────────────────────────────────
|
|
303
|
+
* The form's local branch used to be a bare text field, so "add the repo I am looking at" meant
|
|
304
|
+
* knowing its absolute path and typing it correctly — and the two endpoints that exist to avoid
|
|
305
|
+
* exactly that (`/api/pick-folder`, `/api/fs/list`) had no caller anywhere in the front end.
|
|
306
|
+
*
|
|
307
|
+
* Two ways in, because one of them is not always there: the machine's native dialog first, and the
|
|
308
|
+
* in-app browser when there is no native chooser (headless Linux), when one is already open, or
|
|
309
|
+
* when the PM asks for it. A cancelled dialog changes nothing — it is an answer, not a failure.
|
|
310
|
+
*/
|
|
311
|
+
const browseRowStyle = 'display:grid;grid-template-columns:76px 1fr auto;gap:10px;padding:10px 14px';
|
|
312
|
+
const chooseStyle = 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:32px;'
|
|
313
|
+
+ 'background:transparent;border:1px solid var(--border);color:var(--muted-foreground)';
|
|
314
|
+
|
|
315
|
+
/** The in-app browser, opened at `dir` (empty = the server's idea of home). */
|
|
316
|
+
function openBrowse(dir) {
|
|
317
|
+
// Fixture mode reads NOTHING off this machine — the comparator drives this surface, and a run
|
|
318
|
+
// that opened a dialog or listed the PM's home directory would be doing real work behind a
|
|
319
|
+
// scripted board. It says so instead of degrading into a vaguer "no endpoint" line.
|
|
320
|
+
if (FIXTURE) {
|
|
321
|
+
setState({ addLog: [{ t: 'the folder chooser is off in fixture mode — nothing on this machine is read', c: 'var(--warning)' }] });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!dataMod || typeof dataMod.listFolder !== 'function') {
|
|
325
|
+
setState({ addLog: [{ t: 'no local endpoint for browsing folders — type the path instead', c: 'var(--warning)' }] });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
setState({ addBrowse: { loading: true, path: dir || '', entries: [] } });
|
|
329
|
+
Promise.resolve(dataMod.listFolder(dir)).then((res) => {
|
|
330
|
+
if (!res || res.ok === false) {
|
|
331
|
+
setState({ addBrowse: { path: dir || '', entries: [], error: (res && res.error) || 'that folder could not be listed' } });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setState({ addBrowse: res });
|
|
335
|
+
}).catch((e) => setState({ addBrowse: { path: dir || '', entries: [], error: e && e.message ? e.message : String(e) } }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Ask the server to open the machine's folder dialog; fall back to the in-app browser. */
|
|
339
|
+
function chooseFolder() {
|
|
340
|
+
if (FIXTURE || !dataMod || typeof dataMod.chooseFolder !== 'function') { openBrowse(state.addPath); return; }
|
|
341
|
+
setState({ addPicking: true });
|
|
342
|
+
Promise.resolve(dataMod.chooseFolder(state.addPath)).then((res) => {
|
|
343
|
+
setState({ addPicking: false });
|
|
344
|
+
if (res && res.ok && res.root) { setState({ addPath: res.root, addBrowse: null }); return; }
|
|
345
|
+
if (res && res.cancelled) return; // the PM closed the dialog: no change, no noise
|
|
346
|
+
openBrowse(state.addPath); // unsupported, busy, or unreachable → in-app
|
|
347
|
+
}).catch(() => { setState({ addPicking: false }); openBrowse(state.addPath); });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Take the folder currently open in the browser as the answer. */
|
|
351
|
+
function useFolder(p) {
|
|
352
|
+
if (!p) return;
|
|
353
|
+
setState({ addPath: p, addBrowse: null });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const browseRow = (label, title, onClick, muted) => el('button', {
|
|
357
|
+
onClick, title,
|
|
358
|
+
style: 'display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px;min-height:28px;text-align:left;'
|
|
359
|
+
+ 'font-family:var(--mono);font-size:11px;color:var(--' + (muted ? 'muted-foreground' : 'foreground') + ')',
|
|
360
|
+
hover: 'background:var(--accent)',
|
|
361
|
+
}, label);
|
|
362
|
+
|
|
363
|
+
/** The browser body: where we are, one row per sub-directory, and a way to take the current one. */
|
|
364
|
+
function browseBody() {
|
|
365
|
+
const b = state.addBrowse;
|
|
366
|
+
const rows = [];
|
|
367
|
+
rows.push(el('div', { style: 'display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--border)' },
|
|
368
|
+
el('span', { style: 'flex:1;font-family:var(--mono);font-size:11px;overflow-wrap:anywhere' }, ip(b.path || '…')),
|
|
369
|
+
el('button', { onClick: () => useFolder(b.path), style: chooseStyle + ';min-height:26px', hover: 'border-color:var(--ring)' },
|
|
370
|
+
ip('use this folder')),
|
|
371
|
+
));
|
|
372
|
+
if (b.error) rows.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--destructive)' }, ip(b.error)));
|
|
373
|
+
else if (b.loading) rows.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--muted-foreground)' }, ip('reading…')));
|
|
374
|
+
else {
|
|
375
|
+
const list = [];
|
|
376
|
+
if (b.parent) list.push(browseRow('↑ ..', b.parent, () => openBrowse(b.parent), true));
|
|
377
|
+
for (const e of b.entries || []) {
|
|
378
|
+
// The repo marker is the whole point of showing this list rather than a plain file chooser:
|
|
379
|
+
// it says which of these folders PMOS can actually open as a workspace.
|
|
380
|
+
list.push(browseRow((e.isRepo ? '⑂ ' : '· ') + e.name, e.path, () => openBrowse(e.path), !e.isRepo));
|
|
381
|
+
}
|
|
382
|
+
if (b.denied) list.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--warning)' }, ip('this folder cannot be read')));
|
|
383
|
+
else if (!list.length) list.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--muted-foreground)' }, ip('no sub-folders here')));
|
|
384
|
+
rows.push(el('div', { style: 'max-height:220px;overflow-y:auto' }, list));
|
|
385
|
+
}
|
|
386
|
+
return el('div', { style: 'margin:0 14px 10px;border:1px solid var(--border)' }, rows);
|
|
387
|
+
}
|
|
388
|
+
|
|
302
389
|
function srcButton(id, label) {
|
|
303
390
|
const on = state.addSrc === id;
|
|
304
391
|
return el('button', {
|
|
@@ -425,10 +512,16 @@ export function addProductBody() {
|
|
|
425
512
|
el('button', { onClick: ingestGithub, style: goStyle }, '⇣ connect + ingest'),
|
|
426
513
|
));
|
|
427
514
|
} else {
|
|
428
|
-
|
|
515
|
+
// Field FIRST, chooser beside it: the path is still typeable (and still the thing that gets
|
|
516
|
+
// registered), the button only fills it in. The row keeps label + input as its first two
|
|
517
|
+
// children, which is the shape panel 1's suite reads.
|
|
518
|
+
out.push(el('div', { style: browseRowStyle },
|
|
429
519
|
el('span', { style: labelStyle }, 'folder'),
|
|
430
520
|
field('addPath', state.addPath),
|
|
521
|
+
el('button', { onClick: chooseFolder, style: chooseStyle, hover: 'border-color:var(--ring)' },
|
|
522
|
+
ip(state.addPicking ? 'choosing…' : 'choose…')),
|
|
431
523
|
));
|
|
524
|
+
if (state.addBrowse) out.push(browseBody());
|
|
432
525
|
out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
433
526
|
'Starts with an empty board — nothing is imported from a bare folder. File the first initiative to begin.'));
|
|
434
527
|
out.push(el('div', { style: 'padding:2px 14px 12px;border-bottom:1px solid var(--border)' },
|
|
@@ -28,6 +28,10 @@ function initial() {
|
|
|
28
28
|
product: null, newSessProfile: 'claude-work', intgSynced: {},
|
|
29
29
|
addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
|
|
30
30
|
addLog: [], addedProducts: [], extraKnow: {},
|
|
31
|
+
// the folder chooser's two transient bits: the in-app browser's current listing (null = closed)
|
|
32
|
+
// and whether the machine's native dialog is open. Neither is persisted — a dialog that
|
|
33
|
+
// "reopens" on reload is a lie about what the OS is showing.
|
|
34
|
+
addBrowse: null, addPicking: false,
|
|
31
35
|
newType: 'init', newName: '', newSlug: '', newAnchor: null, newFlow: 'adlc-feature',
|
|
32
36
|
userGroups: {}, userKrs: {}, addedInits: {}, userInits: {}, selTask: null, collapsedPhases: {},
|
|
33
37
|
detail: { kind: 'init' },
|
package/workspace/server.js
CHANGED
|
@@ -121,7 +121,11 @@ const CLONE_DIR = path.join(STATE, 'clones');
|
|
|
121
121
|
// PMOS's OWN memory of which profile to preselect per agent binary (profiles R8.2). It is a
|
|
122
122
|
// workspace preference, not an agent attribute — the file lives here for exactly that reason.
|
|
123
123
|
const PREFS_FILE = path.join(STATE, 'prefs.json');
|
|
124
|
-
const
|
|
124
|
+
const discovery = require('./lib/server-discovery'); // how a launcher FINDS a running server
|
|
125
|
+
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : discovery.DEFAULT_PORT;
|
|
126
|
+
// Written on listen, removed on shutdown. This is the server's answer to "where am I?" — see
|
|
127
|
+
// lib/server-discovery.js for why a launcher must ask rather than assume.
|
|
128
|
+
const PID_FILE = path.join(STATE, discovery.pidFileName(PORT));
|
|
125
129
|
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
126
130
|
const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
|
|
127
131
|
// Bind + token policy (D62). DEFAULT = loopback only, no token (unchanged). The opt-in remote flag
|
|
@@ -3909,6 +3913,7 @@ if (require.main === module) {
|
|
|
3909
3913
|
if (shuttingDown) return; shuttingDown = true;
|
|
3910
3914
|
for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
|
|
3911
3915
|
for (const l of listeners) { try { l.close(); } catch {} }
|
|
3916
|
+
try { fs.unlinkSync(PID_FILE); } catch {} // stop being discoverable the moment we stop serving
|
|
3912
3917
|
process.exit(0);
|
|
3913
3918
|
};
|
|
3914
3919
|
process.on('SIGTERM', shutdown);
|
|
@@ -3916,7 +3921,9 @@ if (require.main === module) {
|
|
|
3916
3921
|
const onError = (e) => {
|
|
3917
3922
|
if (e.code === 'EADDRINUSE') {
|
|
3918
3923
|
console.error(`✗ port ${PORT} is already in use (another workspace server running?).`);
|
|
3919
|
-
console.error(`
|
|
3924
|
+
console.error(` \`pmos\` finds a running workspace on any port and reuses it, and picks a free`);
|
|
3925
|
+
console.error(` port when something else holds this one. \`pmos stop\` shuts running ones down.`);
|
|
3926
|
+
console.error(` To pin a port by hand: PMOS_WS_PORT=7500 npm start`);
|
|
3920
3927
|
process.exit(1);
|
|
3921
3928
|
}
|
|
3922
3929
|
if (e.code === 'EADDRNOTAVAIL') {
|
|
@@ -3931,7 +3938,13 @@ if (require.main === module) {
|
|
|
3931
3938
|
l.on('error', onError);
|
|
3932
3939
|
l.listen(PORT, addr, () => {
|
|
3933
3940
|
// stdout only — no request/header logging (D08). Banner (incl. any generated token) printed once.
|
|
3934
|
-
if (++up
|
|
3941
|
+
if (++up !== BIND.binds.length) return;
|
|
3942
|
+
// Record the port WE are serving, whoever started us. The launcher discovers a running
|
|
3943
|
+
// workspace from these files, so a server started by `npm start` (which is exactly what the
|
|
3944
|
+
// old EADDRINUSE advice told the PM to do, on another port) has to leave the same trace as
|
|
3945
|
+
// one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
|
|
3946
|
+
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
3947
|
+
for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
|
|
3935
3948
|
});
|
|
3936
3949
|
});
|
|
3937
3950
|
}
|