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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.6.1",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@005408d
1
+ kernel-source: pmos@2e880f8
2
2
  assembled: 2026-07-27
3
3
  mode: vendor
@@ -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
@@ -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'))) return true;
79
- say('First run — installing the workspace (one-time, ~20s)…');
80
- const r = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm',
81
- ['install', '--no-audit', '--no-fund'], { cwd: WS, stdio: 'inherit' });
82
- if (r.status !== 0) {
83
- say('✗ install failed. Run `npm run doctor` inside the workspace folder for the exact fix.');
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: process.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 {}
@@ -27,36 +27,55 @@ function binaryArch(file) {
27
27
  }
28
28
 
29
29
  (async () => {
30
- console.log(`pmos workspace doctor — node ${process.version} (${process.platform}/${process.arch})\n`);
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 >= 18) ok(`Node ${process.version} (>= 18)`);
35
- else bad(`Node ${process.version} is too old`, 'install Node 18+ (arm64-native on Apple Silicon)');
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. native binary arch matches the running Node (the posix_spawnp culprit)
46
- const helper = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release',
47
- process.platform === 'win32' ? 'pty.node' : 'spawn-helper');
48
- const alt = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release', 'pty.node');
49
- const bin = fs.existsSync(helper) ? helper : (fs.existsSync(alt) ? alt : null);
50
- if (!bin) bad('node-pty native binary not found (build incomplete)', 'rm -rf node_modules && npm install');
51
- else {
52
- const info = binaryArch(bin);
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 binary present${got && got !== 'universal' && info.x === 'Mach-O' ? ` (${got})` : ''}`);
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: rm -rf node_modules && npm install`);
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
- ? 'this is the broken-native-build signature — npm rebuild node-pty --build-from-source (and see the arch check above)'
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, missing: {} };
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
  }
@@ -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: shown === 'quiet' ? `${w} ${shortDur(quietMs)}` : w };
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
- <span class="sub">${esc(s.agent)} · ${esc(s.cwd)}</span>
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}
@@ -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 (diagnoses arch/toolchain)');
40
- console.error(' npm rebuild node-pty --build-from-source');
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
- const SELF_ROOT = path.resolve(__dirname, '..'); // the repo the workspace ships inside
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
- function homeWorkspace() { return workspaces.get(wsId(SELF_ROOT)); }
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` almost always means the
796
- // node-pty NATIVE BINARY is broken/wrong-arch (e.g. x64 Node on Apple Silicon) — a shell change
797
- // won't fix that; a rebuild will.
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}). This is usually a broken node-pty native build ` +
802
- `(arch mismatch). Fix: stop the server, run \`npm run doctor\` in workspace/, ` +
803
- `then \`npm rebuild node-pty --build-from-source\`.`;
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.extractHttpToken(req.headers), expectedToken: TOKEN })) {
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.extractWsToken(req), expectedToken: TOKEN })) {
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); // the home workspace is always hosted
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
- if ([...sessions.values()].filter((s) => s.pty).length === 0) {
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