create-pmos 0.6.1 → 0.6.2
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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/okf/core/concepts/frontmatter-format.md +4 -1
- package/workspace/bin/pmos.js +40 -7
- package/workspace/doctor.js +35 -16
- package/workspace/lib/remote-access.js +24 -0
- package/workspace/lib/transcript-read.js +38 -1
- package/workspace/public/app.js +34 -2
- package/workspace/public/styles.css +9 -0
- package/workspace/server.js +73 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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
|
@@ -57,7 +57,10 @@ The enforced list lives in [`scripts/frontmatter_registry.txt`](/scripts/frontma
|
|
|
57
57
|
the single source `frontmatter_lint.py` reads. A `type:` value not in it fails CI (this catches typos).
|
|
58
58
|
|
|
59
59
|
- **OKF core:** `Concept`, `ADR`, `Template`, `Playbook`, `Reference`
|
|
60
|
-
- **Planning:** `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
60
|
+
- **Planning:** `Initiative`, `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
61
|
+
- `Initiative` is the repo-native initiative record in [`planning/initiatives/`](/planning/initiatives/), read by the
|
|
62
|
+
[Initiative Gate](/.github/scripts/initiative-gate.sh). Its D56 **work-type lane** rides a separate `lane:` key —
|
|
63
|
+
not `type:` — so the lane vocabulary and this registry never collide.
|
|
61
64
|
- **Root governance:** `Decision`, `Principle`, `Debt`, `Discovery`, `Guide`
|
|
62
65
|
|
|
63
66
|
### Extending the vocabulary
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -74,13 +74,41 @@ function openBrowser(url) {
|
|
|
74
74
|
try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); } catch {}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// node-pty compiles pty.node (require loads it) and, on macOS only, a separate spawn-helper
|
|
78
|
+
// binary (exec'd at spawn — Linux uses forkpty directly and never builds it). A partial macOS
|
|
79
|
+
// build with pty.node but no spawn-helper loads fine yet dies with 'posix_spawnp failed' on every
|
|
80
|
+
// session — the #1 tenant failure, common on a brand-new/non-LTS Node. So "installed" is not
|
|
81
|
+
// "works": verify the artifacts the running platform actually needs, not just the package folder.
|
|
82
|
+
function nodePtyBuildOk() {
|
|
83
|
+
const rel = path.join(WS, 'node_modules', 'node-pty', 'build', 'Release');
|
|
84
|
+
if (!fs.existsSync(path.join(rel, 'pty.node'))) return false;
|
|
85
|
+
return process.platform !== 'darwin' || fs.existsSync(path.join(rel, 'spawn-helper'));
|
|
86
|
+
}
|
|
87
|
+
function npm(args) {
|
|
88
|
+
return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args,
|
|
89
|
+
{ cwd: WS, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
|
|
90
|
+
}
|
|
77
91
|
function ensureInstalled() {
|
|
78
|
-
if (fs.existsSync(path.join(WS, 'node_modules', 'node-pty')))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
if (!fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) {
|
|
93
|
+
say('First run — installing the workspace (one-time, ~20s)…');
|
|
94
|
+
if (!npm(['install', '--no-audit', '--no-fund'])) {
|
|
95
|
+
say('✗ install failed. Run `pmos doctor` for the exact fix.');
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Self-heal a partial/incomplete native build (node-pty ships no prebuilds, so `npm rebuild`
|
|
100
|
+
// compiles from source) BEFORE the server tries to spawn a session and dies with posix_spawnp.
|
|
101
|
+
if (!nodePtyBuildOk()) {
|
|
102
|
+
say('node-pty is present but its native build is incomplete — rebuilding from source…');
|
|
103
|
+
npm(['rebuild', 'node-pty']);
|
|
104
|
+
}
|
|
105
|
+
if (!nodePtyBuildOk()) {
|
|
106
|
+
const toolchain = process.platform === 'darwin' ? 'xcode-select --install'
|
|
107
|
+
: process.platform === 'win32' ? 'Visual Studio Build Tools (C++)'
|
|
108
|
+
: 'apt install build-essential python3';
|
|
109
|
+
say('✗ node-pty could not be built on this machine. Run `pmos doctor` for the full diagnosis.');
|
|
110
|
+
say(` Most often: install a C/C++ toolchain (${toolchain}),`);
|
|
111
|
+
say(` or switch to Node 22 LTS (you are on ${process.version}; a very new/non-LTS Node may not build node-pty).`);
|
|
84
112
|
return false;
|
|
85
113
|
}
|
|
86
114
|
return true;
|
|
@@ -109,8 +137,13 @@ async function up(dir) {
|
|
|
109
137
|
if (!ensureInstalled()) return 1;
|
|
110
138
|
fs.mkdirSync(STATE, { recursive: true });
|
|
111
139
|
const log = fs.openSync(LOGFILE, 'a');
|
|
140
|
+
// Tell the server which repo is HOME. An installed/vendored workspace does not live inside the
|
|
141
|
+
// repo it manages (it's under node_modules or an npx bundle), so the server cannot derive home
|
|
142
|
+
// from its own location — without this it would register the PACKAGE folder as "Home" (the
|
|
143
|
+
// tenant defect). Pass the repo we're opening; a caller-set PMOS_SELF_ROOT still wins.
|
|
144
|
+
const env = { ...process.env, PMOS_SELF_ROOT: process.env.PMOS_SELF_ROOT || repoRootOf(dir) };
|
|
112
145
|
const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
|
|
113
|
-
cwd: WS, detached: true, stdio: ['ignore', log, log], env
|
|
146
|
+
cwd: WS, detached: true, stdio: ['ignore', log, log], env,
|
|
114
147
|
});
|
|
115
148
|
child.unref();
|
|
116
149
|
try { fs.writeFileSync(PIDFILE, String(child.pid)); } catch {}
|
package/workspace/doctor.js
CHANGED
|
@@ -27,36 +27,55 @@ function binaryArch(file) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
(async () => {
|
|
30
|
-
|
|
30
|
+
const WSDIR = __dirname; // the install dir the fixes operate in
|
|
31
|
+
console.log(`pmos workspace doctor — node ${process.version} (${process.platform}/${process.arch})`);
|
|
32
|
+
console.log(` install dir: ${WSDIR}\n`);
|
|
33
|
+
|
|
34
|
+
// node-pty ships NO prebuilds — it compiles a native addon on install — so `npm rebuild
|
|
35
|
+
// node-pty` (or a clean reinstall) already builds from source; the old `--build-from-source`
|
|
36
|
+
// npm flag is unnecessary and newer npm rejects it as unknown config. These are the canonical
|
|
37
|
+
// fixes, dir-qualified so they are copy-pasteable from anywhere.
|
|
38
|
+
const TOOLCHAIN = process.platform === 'darwin' ? 'xcode-select --install'
|
|
39
|
+
: process.platform === 'win32' ? 'Visual Studio Build Tools (C++)'
|
|
40
|
+
: 'apt install build-essential python3';
|
|
41
|
+
const REINSTALL = `cd "${WSDIR}" && rm -rf node_modules && npm install (recompiles node-pty from source; needs a C/C++ toolchain: ${TOOLCHAIN})`;
|
|
42
|
+
const REINSTALL_LTS = `${REINSTALL}\n If it still fails on Node ${process.version}, that Node may be too new/non-LTS for node-pty to build — switch to Node 22 LTS and retry.`;
|
|
31
43
|
|
|
32
44
|
// 1. Node version
|
|
33
45
|
const major = Number(process.versions.node.split('.')[0]);
|
|
34
|
-
if (major
|
|
35
|
-
else
|
|
46
|
+
if (major < 18) bad(`Node ${process.version} is too old`, 'install Node 18+ (arm64-native on Apple Silicon)');
|
|
47
|
+
else if (major % 2 === 1) { checks.push(true); console.log(`✓ Node ${process.version} (>= 18)`); console.log(` • note: Node ${major} is an ODD (non-LTS) line — node-pty may lack a matching build. If the checks below fail, try Node 22 LTS.`); }
|
|
48
|
+
else ok(`Node ${process.version} (>= 18)`);
|
|
36
49
|
|
|
37
50
|
// 2. node-pty loads
|
|
38
51
|
let pty = null;
|
|
39
52
|
try { pty = require('node-pty'); ok('node-pty native module loads'); }
|
|
40
53
|
catch (e) {
|
|
41
|
-
bad(`node-pty failed to load: ${e.message.split('\n')[0]}`,
|
|
42
|
-
'npm rebuild node-pty --build-from-source (needs: macOS `xcode-select --install`; Linux `apt install build-essential python3`)');
|
|
54
|
+
bad(`node-pty failed to load: ${e.message.split('\n')[0]}`, REINSTALL_LTS);
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
// 3.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
// 3. the compiled binaries are present + arch-matched (the posix_spawnp culprit). node-pty
|
|
58
|
+
// compiles pty.node (loaded by require) and, on macOS ONLY, a separate spawn-helper binary
|
|
59
|
+
// (exec'd at spawn time — Linux uses forkpty directly and never builds it). A PARTIAL macOS
|
|
60
|
+
// build — pty.node present but spawn-helper missing — loads fine (check 2 passes) yet every
|
|
61
|
+
// session dies with 'posix_spawnp failed'. That is exactly the field failure on a too-new
|
|
62
|
+
// Node, so on macOS check both, not just one.
|
|
63
|
+
const rel = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release');
|
|
64
|
+
const ptyNode = path.join(rel, 'pty.node');
|
|
65
|
+
const helper = path.join(rel, 'spawn-helper');
|
|
66
|
+
if (!fs.existsSync(ptyNode)) {
|
|
67
|
+
bad('node-pty binary pty.node not found (compile did not complete)', REINSTALL_LTS);
|
|
68
|
+
} else if (process.platform === 'darwin' && !fs.existsSync(helper)) {
|
|
69
|
+
bad("node-pty's spawn-helper did not compile — pty.node loads but every session dies with 'posix_spawnp failed'", REINSTALL_LTS);
|
|
70
|
+
} else {
|
|
71
|
+
const info = binaryArch(ptyNode);
|
|
53
72
|
const want = process.arch; // arm64 | x64 | …
|
|
54
73
|
const got = info && info.arch;
|
|
55
74
|
const match = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
|
|
56
75
|
(want === 'arm64' && got === 'arm64') || (want === 'x64' && got === 'x86_64');
|
|
57
|
-
if (match) ok(`native
|
|
76
|
+
if (match) ok(`native binaries present${got && got !== 'universal' && info.x === 'Mach-O' ? ` (${got})` : ''}`);
|
|
58
77
|
else bad(`ARCH MISMATCH: Node is ${want} but node-pty's binary is ${got} — this causes 'posix_spawnp failed' on every session`,
|
|
59
|
-
`install a ${want}-native Node (e.g. \`brew install node\` on an arm64 shell), then:
|
|
78
|
+
`install a ${want}-native Node (e.g. \`brew install node\` on an arm64 shell), then: ${REINSTALL}`);
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
// 4. a real PTY spawns and echoes
|
|
@@ -75,7 +94,7 @@ function binaryArch(file) {
|
|
|
75
94
|
} catch (e) {
|
|
76
95
|
bad(`PTY spawn failed: ${e.message}`,
|
|
77
96
|
/posix_spawnp/.test(e.message)
|
|
78
|
-
?
|
|
97
|
+
? `broken-native-build signature (wrong-arch or spawn-helper missing). ${REINSTALL_LTS}`
|
|
79
98
|
: process.platform === 'win32' ? 'native Windows needs WSL for a real PTY — run the workspace inside WSL'
|
|
80
99
|
: 'check that /bin/sh exists and is executable');
|
|
81
100
|
}
|
|
@@ -131,6 +131,29 @@ function extractWsToken(req = {}) {
|
|
|
131
131
|
}
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
134
|
+
// A browser only carries the token on the FIRST navigation URL — it cannot attach a header or query
|
|
135
|
+
// to sub-resource loads (CSS/JS) or the WebSocket upgrade. So a valid ?token= navigation SEEDS a
|
|
136
|
+
// same-origin cookie (see server.js httpGuard) and every later request presents the token via that
|
|
137
|
+
// cookie. Parse it here. The cookie is still gated by the Origin/Host allowlist, and its value is
|
|
138
|
+
// redacted from any log line (D08).
|
|
139
|
+
const COOKIE_NAME = 'pmos_token';
|
|
140
|
+
function extractCookieToken(headers = {}) {
|
|
141
|
+
const raw = headers.cookie || headers.Cookie || '';
|
|
142
|
+
if (!raw) return null;
|
|
143
|
+
for (const part of String(raw).split(';')) {
|
|
144
|
+
const eq = part.indexOf('=');
|
|
145
|
+
if (eq < 0) continue;
|
|
146
|
+
if (part.slice(0, eq).trim() === COOKIE_NAME) {
|
|
147
|
+
try { return decodeURIComponent(part.slice(eq + 1).trim()); } catch { return part.slice(eq + 1).trim(); }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
// The single presented-token resolver used by BOTH guards: Authorization header, then ?token= query,
|
|
153
|
+
// then a bearer subprotocol (WS handshakes), then the seeded cookie (browser sub-resources + WS).
|
|
154
|
+
function presentedToken(req = {}) {
|
|
155
|
+
return extractWsToken(req) || extractCookieToken(req.headers || {});
|
|
156
|
+
}
|
|
134
157
|
|
|
135
158
|
// ── Origin / Host allowlist (D64 / pivot-workspace-first, Track A1) ─────────────────────
|
|
136
159
|
// The threat: the server owns real PTYs and, for loopback peers, is token-EXEMPT. Without an
|
|
@@ -232,4 +255,5 @@ module.exports = {
|
|
|
232
255
|
LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
|
|
233
256
|
resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
|
|
234
257
|
checkHostOrigin, isAllowedOrigin, hostnameAllowed,
|
|
258
|
+
COOKIE_NAME, extractCookieToken, presentedToken,
|
|
235
259
|
};
|
|
@@ -167,7 +167,8 @@ function prettyTool(name) {
|
|
|
167
167
|
* reach the screen goes through it (D08), because tool names and skill names are free text.
|
|
168
168
|
*/
|
|
169
169
|
function readHarness(file, clean = (x) => x) {
|
|
170
|
-
const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null,
|
|
170
|
+
const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null,
|
|
171
|
+
title: null, openCall: null, missing: {} };
|
|
171
172
|
let raw;
|
|
172
173
|
try {
|
|
173
174
|
const st = fs.statSync(file);
|
|
@@ -189,11 +190,35 @@ function readHarness(file, clean = (x) => x) {
|
|
|
189
190
|
let toolTotal = 0;
|
|
190
191
|
const tok = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
191
192
|
let sawUsage = false;
|
|
193
|
+
// For the open-call signal (R2): pair tool_use → tool_result by id in one pass. The NEWEST
|
|
194
|
+
// tool_use with no matching result is the call in flight; its record timestamp dates it. This is
|
|
195
|
+
// the same "written before its result exists" property the append-only transcript guarantees.
|
|
196
|
+
// NOTE the honesty bound lives in the CALLER (server): a dangling call on a crashed/interrupted
|
|
197
|
+
// session is a permanent phantom (measured: 2/25 real transcripts), so the age is rendered ONLY
|
|
198
|
+
// while the session's derived state is `working` — age without liveness is refused. Here we only
|
|
199
|
+
// report the raw fact {verb, sinceTs}; we never claim it is live.
|
|
200
|
+
const openUse = new Map(); // tool_use_id → { verb, ts } (insertion order = chronological)
|
|
201
|
+
const resolved = new Set(); // tool_use_ids that got a tool_result
|
|
192
202
|
|
|
193
203
|
for (const line of raw.split('\n')) {
|
|
194
204
|
if (!line) continue;
|
|
195
205
|
let r;
|
|
196
206
|
try { r = JSON.parse(line); } catch { continue; } // a torn line is skipped, never guessed
|
|
207
|
+
// The CLI's own rolling one-line session title (R1). It is FROZEN at the first prompt (every
|
|
208
|
+
// session carries exactly one, no timestamp), so the UI labels it "opening focus", never
|
|
209
|
+
// "current". Last one wins in case the layout ever changes.
|
|
210
|
+
if (r.type === 'ai-title') {
|
|
211
|
+
if (typeof r.aiTitle === 'string' && r.aiTitle.trim()) out.title = clean(r.aiTitle.trim());
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// tool_result blocks live inside `user` records; harvest the ids they resolve.
|
|
215
|
+
if (r.type === 'user') {
|
|
216
|
+
const um = r.message || {};
|
|
217
|
+
for (const c of (Array.isArray(um.content) ? um.content : [])) {
|
|
218
|
+
if (c && c.type === 'tool_result' && c.tool_use_id) resolved.add(c.tool_use_id);
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
197
222
|
if (r.type === 'attachment') {
|
|
198
223
|
const a = r.attachment || {};
|
|
199
224
|
if (a.type === 'skill_listing' && typeof a.skillCount === 'number') {
|
|
@@ -224,10 +249,17 @@ function readHarness(file, clean = (x) => x) {
|
|
|
224
249
|
const name = clean(prettyTool(c.name));
|
|
225
250
|
toolCounts[name] = (toolCounts[name] || 0) + 1;
|
|
226
251
|
toolTotal++;
|
|
252
|
+
if (c.id) openUse.set(c.id, { verb: name, ts: r.timestamp || null });
|
|
227
253
|
}
|
|
228
254
|
}
|
|
229
255
|
}
|
|
230
256
|
|
|
257
|
+
// The newest tool_use with no matching tool_result is the call in flight (R2). Map preserves
|
|
258
|
+
// insertion (chronological) order, so the last unresolved entry is the newest.
|
|
259
|
+
for (const [id, info] of openUse) {
|
|
260
|
+
if (!resolved.has(id)) out.openCall = { verb: info.verb, sinceTs: info.ts };
|
|
261
|
+
}
|
|
262
|
+
|
|
231
263
|
if (!out.model) out.missing.model = 'the transcript names no model yet';
|
|
232
264
|
if (!out.effort) out.missing.effort = 'the transcript records no effort level';
|
|
233
265
|
if (toolTotal) out.tools = { counts: toolCounts, total: toolTotal };
|
|
@@ -239,6 +271,11 @@ function readHarness(file, clean = (x) => x) {
|
|
|
239
271
|
else out.missing.tokens = 'the transcript records no token usage yet';
|
|
240
272
|
if (!out.skills) out.missing.skills = 'the CLI listed no skills for this session';
|
|
241
273
|
if (!out.turns) out.missing.turns = 'no model turns in the transcript yet';
|
|
274
|
+
// NOTE: `title` and `openCall` deliberately get NO `missing` entry. This map carries reasons the
|
|
275
|
+
// UI RENDERS in place of a value; both of these instead fall back to a real shipped rendering
|
|
276
|
+
// (agent · cwd for an untitled session; the plain `working`/`quiet` word when no call is open).
|
|
277
|
+
// Putting them here would break the map's contract — "empty missing == everything was readable" —
|
|
278
|
+
// and put a reason on screen where a perfectly good value already goes.
|
|
242
279
|
|
|
243
280
|
return out;
|
|
244
281
|
}
|
package/workspace/public/app.js
CHANGED
|
@@ -203,10 +203,28 @@ function shownState(s) {
|
|
|
203
203
|
const q = Math.max(0, Date.now() - s.lastOutputAt);
|
|
204
204
|
return q >= QUIET_AFTER_MS ? { shown: 'quiet', quietMs: q } : { shown: 'working', quietMs: q };
|
|
205
205
|
}
|
|
206
|
+
// The open call's AGE, appended to `working` only. `working · Bash 4m` is the first honest signal
|
|
207
|
+
// for HUNG the workspace has — a call open past a glance is a call that may be stuck. The honesty
|
|
208
|
+
// bound is structural, not a check bolted on: age rides `shown === 'working'`, and shownState only
|
|
209
|
+
// returns 'working' while lastOutputAt is recent, so a crashed/interrupted session whose dangling
|
|
210
|
+
// tool_use is a permanent phantom reads `quiet`/`exited` and NEVER a live age. Age without liveness
|
|
211
|
+
// is refused. `sinceTs` comes from the transcript record; we tick it client-side like the quiet clock.
|
|
212
|
+
function callAge(s, shown) {
|
|
213
|
+
if (shown !== 'working') return '';
|
|
214
|
+
const oc = s.hx && s.hx.openCall;
|
|
215
|
+
if (!oc || !oc.sinceTs) return '';
|
|
216
|
+
const ms = Date.parse(oc.sinceTs);
|
|
217
|
+
if (!Number.isFinite(ms)) return '';
|
|
218
|
+
const age = Date.now() - ms;
|
|
219
|
+
if (age < 0) return '';
|
|
220
|
+
return ` · ${oc.verb} ${shortDur(age)}`;
|
|
221
|
+
}
|
|
206
222
|
function stateLabel(s) {
|
|
207
223
|
const { shown, quietMs } = shownState(s);
|
|
208
224
|
const w = STATE_WORD[shown] || shown;
|
|
209
|
-
return { shown, text:
|
|
225
|
+
if (shown === 'quiet') return { shown, text: `${w} ${shortDur(quietMs)}` };
|
|
226
|
+
if (shown === 'working') return { shown, text: `${w}${callAge(s, shown)}` };
|
|
227
|
+
return { shown, text: w };
|
|
210
228
|
}
|
|
211
229
|
function renderSessions() {
|
|
212
230
|
const mine = mySessions();
|
|
@@ -222,7 +240,7 @@ function renderSessions() {
|
|
|
222
240
|
<span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
|
|
223
241
|
<span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
|
|
224
242
|
<span class="chip">${esc(st.text)}</span></span>
|
|
225
|
-
|
|
243
|
+
${subLine(s)}
|
|
226
244
|
${saysLine(s)}
|
|
227
245
|
</button>
|
|
228
246
|
<button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
|
|
@@ -248,6 +266,17 @@ function renderSessions() {
|
|
|
248
266
|
updateEmptyOverlay();
|
|
249
267
|
paintRename();
|
|
250
268
|
}
|
|
269
|
+
// The sub-line: the run's OWN focus, not `agent · cwd` (which was identical on every row in a
|
|
270
|
+
// workspace — zero information). The CLI's `ai-title` is FROZEN at the first prompt (it carries no
|
|
271
|
+
// timestamp and never updates), so it is labelled "opening focus" and never framed as what the run
|
|
272
|
+
// is doing now. When there is no resolvable title — a shell/codex/untitled/unresolved session — it
|
|
273
|
+
// falls back to the shipped `agent · cwd`, so the line is never blank and never a guessed title.
|
|
274
|
+
// When a title IS shown, `agent · cwd` moves into the harness drawer (see harnessDrawer), not lost.
|
|
275
|
+
function subLine(s) {
|
|
276
|
+
const title = s.hx && s.hx.title;
|
|
277
|
+
if (title) return `<span class="sub"><span class="lead">opening focus</span> ${esc(title)}</span>`;
|
|
278
|
+
return `<span class="sub">${esc(s.agent)} · ${esc(s.cwd)}</span>`;
|
|
279
|
+
}
|
|
251
280
|
// The agent's own last words, verbatim — never a paraphrase (a summary would be another model
|
|
252
281
|
// call per session, and a drifting paraphrase was a leg-A defect). When the parser cannot read,
|
|
253
282
|
// it says so: an unreadable signal must never render as an empty line.
|
|
@@ -297,6 +326,9 @@ function harnessDrawer(s) {
|
|
|
297
326
|
`${hx.skills.names.length ? ` <span class="src">${esc(hx.skills.names.slice(0, 5).join(', '))}…</span>` : ''}` +
|
|
298
327
|
` <span class="src">available, not “in use” — the CLI records no such event</span></dd>`
|
|
299
328
|
: `<dt>skills</dt>${na(m.skills || 'not available')}`);
|
|
329
|
+
// agent · cwd moved here from the row's sub-line (which now carries the run's title). Useful
|
|
330
|
+
// once, when reading a session — not worth a per-row line where it repeated on every session.
|
|
331
|
+
rows.push(`<dt>where</dt><dd>${esc(s.agent)} <span class="src">${esc(s.cwd)}</span></dd>`);
|
|
300
332
|
rows.push('<dt>instructions</dt><dd>CLAUDE.md · AGENTS.md <span class="src">identical in every session here</span></dd>');
|
|
301
333
|
// The one row that stays unavailable, and it is architectural rather than a parsing limit:
|
|
302
334
|
// Quality gate state is GitHub CI, Review gate state is hosted Supabase, and the workspace
|
|
@@ -164,6 +164,10 @@ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
|
164
164
|
.sess .sub{font-size:10.5px;color:var(--text-muted);margin:2px 0 0 18px;display:block;
|
|
165
165
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
166
166
|
.sess[data-state="blocked"] .sub{color:var(--warn-text)}
|
|
167
|
+
/* "opening focus" — the permanent lead label on the run-title sub-line. It states plainly that the
|
|
168
|
+
title is the run's FIRST prompt (frozen, no timestamp), never its current activity. */
|
|
169
|
+
.sess .sub .lead{font-family:var(--mono);font-size:8px;letter-spacing:.06em;text-transform:uppercase;
|
|
170
|
+
color:var(--text-dim);margin-right:5px}
|
|
167
171
|
/* ── harness tower (workspace-harness-tower): the state chip, the agent's own last words,
|
|
168
172
|
and the per-session harness drill-in. State is carried by SHAPE (the glyph), HUE, and the
|
|
169
173
|
WORD in the chip — three channels, so nothing depends on colour alone. ── */
|
|
@@ -232,6 +236,11 @@ details.hz .kv .src{font-family:var(--mono);font-size:8.5px;color:var(--text-dim
|
|
|
232
236
|
.termpane{position:absolute;inset:0;padding:8px 8px 6px 10px;display:none}
|
|
233
237
|
.termpane.on{display:block}
|
|
234
238
|
.termpane .xterm{height:100%}
|
|
239
|
+
/* Touch scrollback: xterm's .xterm-viewport owns the scroll, but iOS Safari won't pan it by swipe
|
|
240
|
+
without an explicit touch-action + momentum flag (the terminal otherwise swallows the gesture).
|
|
241
|
+
Presentation-only; harmless on pointer devices. */
|
|
242
|
+
.termpane .xterm .xterm-viewport{touch-action:pan-y;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
|
|
243
|
+
.termpane .xterm .xterm-screen{touch-action:pan-y}
|
|
235
244
|
/* scoped to termhost (its positioned ancestor) — sidebar/tabs/needs stay clickable */
|
|
236
245
|
.exited-overlay{position:absolute;inset:0;z-index:5;display:none;place-items:center;background:var(--glass)}
|
|
237
246
|
.exited-overlay.on{display:grid}
|
package/workspace/server.js
CHANGED
|
@@ -36,8 +36,9 @@ try {
|
|
|
36
36
|
} catch (e) {
|
|
37
37
|
console.error('✗ node-pty failed to load — the native module is missing or built for the wrong platform.');
|
|
38
38
|
console.error(` ${e.message.split('\n')[0]}`);
|
|
39
|
-
console.error(' Fix: npm run doctor
|
|
40
|
-
console.error(' npm
|
|
39
|
+
console.error(' Fix: npm run doctor (diagnoses arch/toolchain/Node version)');
|
|
40
|
+
console.error(' rm -rf node_modules && npm install (recompiles node-pty from source)');
|
|
41
|
+
console.error(` node-pty compiles a native addon — needs a C/C++ toolchain (macOS: xcode-select --install; Debian/Ubuntu: apt install build-essential python3) and a supported Node LTS. You are on ${process.version}; if it is a very new or non-LTS release the build may fail — try Node 22 LTS.`);
|
|
41
42
|
process.exit(1);
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,7 +51,28 @@ const remoteAccess = require('./lib/remote-access'); // tailnet bind + token g
|
|
|
50
51
|
const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
|
|
51
52
|
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
52
53
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
53
|
-
|
|
54
|
+
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
55
|
+
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
56
|
+
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
57
|
+
// there the parent is a PACKAGE dir, not a repo. Deriving the home from __dirname then made the
|
|
58
|
+
// package folder masquerade as the home workspace (the tenant defect: the GUI showed the
|
|
59
|
+
// create-pmos package as "Home" instead of the repo the PM launched). So: the launcher passes
|
|
60
|
+
// PMOS_SELF_ROOT = the repo it opened; that becomes home. Absent the env, we only adopt the
|
|
61
|
+
// parent as home when it genuinely looks like the PMOS repo the app is embedded in. Otherwise
|
|
62
|
+
// there is NO privileged home — every registered repo is equal.
|
|
63
|
+
const PARENT_DIR = path.resolve(__dirname, '..');
|
|
64
|
+
const INSTALLED_COPY = /[\\/]node_modules[\\/]/.test(__dirname); // npm i -g / vendored install
|
|
65
|
+
function dirExists(p) { try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
66
|
+
const SELF_ROOT = process.env.PMOS_SELF_ROOT ? path.resolve(process.env.PMOS_SELF_ROOT) : PARENT_DIR;
|
|
67
|
+
// EMBEDDED_SELF: the app physically lives inside SELF_ROOT (genuine PMOS-self). Only then is the
|
|
68
|
+
// server's own state/ the file-mode mirror (stateDirFor) — a tenant home must write to its OWN
|
|
69
|
+
// repo's pmos/state, never into this installed package.
|
|
70
|
+
const EMBEDDED_SELF = !INSTALLED_COPY && SELF_ROOT === PARENT_DIR &&
|
|
71
|
+
(fs.existsSync(path.join(PARENT_DIR, 'DECISIONS.md')) || fs.existsSync(path.join(PARENT_DIR, 'AGENTS.md')));
|
|
72
|
+
// HOME_ENABLED: is there a privileged home workspace at all? Yes when embedded, or when the
|
|
73
|
+
// launcher named a real repo via PMOS_SELF_ROOT. An installed copy launched WITHOUT that env
|
|
74
|
+
// (unsupported direct run) has no home — the client must open a repo to do anything.
|
|
75
|
+
const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists(SELF_ROOT));
|
|
54
76
|
const PUB = path.join(__dirname, 'public');
|
|
55
77
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
56
78
|
const STATE = path.join(__dirname, 'state');
|
|
@@ -149,16 +171,21 @@ function registerWorkspace(root, name) {
|
|
|
149
171
|
// while it still owns sessions, so scrollback is never orphaned by a click.
|
|
150
172
|
function forgetWorkspace(id) {
|
|
151
173
|
const w = workspaces.get(id);
|
|
152
|
-
if (!w || w.root === SELF_ROOT) return false;
|
|
174
|
+
if (!w || (HOME_ENABLED && w.root === SELF_ROOT)) return false;
|
|
153
175
|
if ([...sessions.values()].some((s) => s.ws === id)) return false;
|
|
154
176
|
workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
|
|
155
177
|
return true;
|
|
156
178
|
}
|
|
157
|
-
|
|
179
|
+
// The home is SELF_ROOT when enabled; otherwise (installed copy, no launcher env) there is no
|
|
180
|
+
// privileged home, so an unresolved ws id falls back to the first registered repo rather than
|
|
181
|
+
// resolving to the package dir.
|
|
182
|
+
function homeWorkspace() {
|
|
183
|
+
return HOME_ENABLED ? workspaces.get(wsId(SELF_ROOT)) : [...workspaces.values()][0];
|
|
184
|
+
}
|
|
158
185
|
function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
|
|
159
186
|
function wsList() {
|
|
160
187
|
return [...workspaces.values()].map((w) => ({
|
|
161
|
-
id: w.id, name: w.name, root: w.root, home: w.root === SELF_ROOT,
|
|
188
|
+
id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
162
189
|
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
163
190
|
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
164
191
|
}));
|
|
@@ -792,15 +819,20 @@ function appendBuf(s, data) {
|
|
|
792
819
|
s.parsed = null; // buffer moved; the cached parse is stale
|
|
793
820
|
}
|
|
794
821
|
|
|
795
|
-
// Platform-correct guidance when a spawn fails. `posix_spawnp failed`
|
|
796
|
-
//
|
|
797
|
-
//
|
|
822
|
+
// Platform-correct guidance when a spawn fails. `posix_spawnp failed` means node-pty's native
|
|
823
|
+
// build is broken: either a wrong-arch binary (e.g. x64 Node on Apple Silicon) or an incomplete
|
|
824
|
+
// compile (the spawn-helper never built — common on a brand-new/non-LTS Node). A shell change
|
|
825
|
+
// won't fix it; a clean recompile will. `npm rebuild node-pty` compiles from source on its own —
|
|
826
|
+
// node-pty ships no prebuilds — so the old `--build-from-source` npm flag is unnecessary (and
|
|
827
|
+
// newer npm rejects it as an unknown config).
|
|
798
828
|
function spawnFailureAdvice(err, cmd, isShell) {
|
|
799
829
|
const msg = String(err && err.message || err);
|
|
800
830
|
if (/posix_spawnp|EFAULT|ENOEXEC|wrong architecture|not valid/i.test(msg)) {
|
|
801
|
-
return `could not spawn '${cmd}' (${msg}).
|
|
802
|
-
`
|
|
803
|
-
`then \`npm
|
|
831
|
+
return `could not spawn '${cmd}' (${msg}). node-pty's native build is broken (wrong-arch, or ` +
|
|
832
|
+
`the spawn-helper never compiled). Fix: stop the server, run \`npm run doctor\` (it names the ` +
|
|
833
|
+
`install dir + the exact cause), then in that dir \`rm -rf node_modules && npm install\`. ` +
|
|
834
|
+
`Needs a C/C++ toolchain; on a very new or non-LTS Node (${process.version}) the compile can ` +
|
|
835
|
+
`fail — try Node 22 LTS.`;
|
|
804
836
|
}
|
|
805
837
|
if (process.platform === 'win32') {
|
|
806
838
|
return `could not start '${cmd}': ${msg}. Native Windows needs WSL for a real PTY shell — ` +
|
|
@@ -1036,7 +1068,7 @@ function stateDirFor(w, { create = false } = {}) {
|
|
|
1036
1068
|
const d = path.join(w.root, ...rel);
|
|
1037
1069
|
if (isDir(d)) return d;
|
|
1038
1070
|
}
|
|
1039
|
-
if (w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror
|
|
1071
|
+
if (EMBEDDED_SELF && w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror (never a tenant home — that writes to its OWN repo below)
|
|
1040
1072
|
// A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
|
|
1041
1073
|
// invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
|
|
1042
1074
|
if (create && wsShape(w.root) === 'pmos') {
|
|
@@ -1353,7 +1385,7 @@ async function handleRequest(req, res) {
|
|
|
1353
1385
|
// tell any open client to switch — otherwise the browser refocuses the old tab and the
|
|
1354
1386
|
// PM sees the previous repo, which is precisely the reported defect
|
|
1355
1387
|
if (body.focus !== false) broadcast({ t: 'focus', ws: w.id });
|
|
1356
|
-
return json(res, { ok: true, workspace: { ...w, home: w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
1388
|
+
return json(res, { ok: true, workspace: { ...w, home: HOME_ENABLED && w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
1357
1389
|
}
|
|
1358
1390
|
if (p === '/api/workspaces/forget' && req.method === 'POST') {
|
|
1359
1391
|
let body = {};
|
|
@@ -1603,12 +1635,29 @@ function httpGuard(req, res) {
|
|
|
1603
1635
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
|
|
1604
1636
|
return;
|
|
1605
1637
|
}
|
|
1638
|
+
// Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
|
|
1639
|
+
// browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
|
|
1640
|
+
// sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
|
|
1641
|
+
// phone flow (REMOTE.md + startup banner) must reach THIS page-load guard, and every later request
|
|
1642
|
+
// rides the cookie we seed just below. presentedToken() resolves all channels. (local hotfix)
|
|
1606
1643
|
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
|
|
1607
|
-
presentedToken: remoteAccess.
|
|
1644
|
+
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
1608
1645
|
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' });
|
|
1609
1646
|
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
1610
1647
|
return;
|
|
1611
1648
|
}
|
|
1649
|
+
// Seed a same-origin session cookie when a VALID token arrived via the ?token= query on a non-loopback
|
|
1650
|
+
// navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
|
|
1651
|
+
// in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
|
|
1652
|
+
// foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
|
|
1653
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress)) {
|
|
1654
|
+
let qTok = null;
|
|
1655
|
+
try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
|
|
1656
|
+
if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
|
|
1657
|
+
res.setHeader('Set-Cookie',
|
|
1658
|
+
`${remoteAccess.COOKIE_NAME}=${encodeURIComponent(TOKEN)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1612
1661
|
return handleRequest(req, res);
|
|
1613
1662
|
}
|
|
1614
1663
|
const server = http.createServer(httpGuard);
|
|
@@ -1626,8 +1675,10 @@ function handleUpgrade(req, socket, head) {
|
|
|
1626
1675
|
socket.destroy();
|
|
1627
1676
|
return;
|
|
1628
1677
|
}
|
|
1678
|
+
// A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
|
|
1679
|
+
// seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
|
|
1629
1680
|
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress,
|
|
1630
|
-
presentedToken: remoteAccess.
|
|
1681
|
+
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
1631
1682
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
1632
1683
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
1633
1684
|
return;
|
|
@@ -1644,7 +1695,7 @@ wss.on('connection', (ws) => {
|
|
|
1644
1695
|
// dropped silently; excess spawns get an honest error rather than piling up shells.
|
|
1645
1696
|
ws._rlSpawn = rateLimit.createBucket(8, 0.5);
|
|
1646
1697
|
ws._rlInput = rateLimit.createBucket(400, 200);
|
|
1647
|
-
ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: wsId(SELF_ROOT), sessions: sessionList() }));
|
|
1698
|
+
ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: HOME_ENABLED ? wsId(SELF_ROOT) : null, sessions: sessionList() }));
|
|
1648
1699
|
|
|
1649
1700
|
ws.on('message', (raw) => {
|
|
1650
1701
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
@@ -1693,12 +1744,14 @@ wss.on('connection', (ws) => {
|
|
|
1693
1744
|
});
|
|
1694
1745
|
|
|
1695
1746
|
loadWorkspaces();
|
|
1696
|
-
registerWorkspace(SELF_ROOT);
|
|
1747
|
+
if (HOME_ENABLED) registerWorkspace(SELF_ROOT); // the home workspace is hosted (PMOS-self, or the repo the launcher named)
|
|
1697
1748
|
writeTmuxConf(); // must exist before restore may reattach or spawn creates
|
|
1698
1749
|
restorePersisted();
|
|
1699
1750
|
|
|
1700
|
-
// A first shell so the workspace is never empty on first run (honest real PTY).
|
|
1701
|
-
|
|
1751
|
+
// A first shell so the workspace is never empty on first run (honest real PTY). Only when there
|
|
1752
|
+
// is a repo to open it IN: an installed copy with no home and no registered repo yet must wait
|
|
1753
|
+
// for the launcher to POST a workspace rather than spawn a shell in the package directory.
|
|
1754
|
+
if ((HOME_ENABLED || workspaces.size) && [...sessions.values()].filter((s) => s.pty).length === 0) {
|
|
1702
1755
|
spawnSession({ name: 'shell', agent: 'shell' });
|
|
1703
1756
|
}
|
|
1704
1757
|
|