create-pmos 0.6.1 → 0.6.3

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.3",
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@f4589bf
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,15 +74,32 @@ function openBrowser(url) {
74
74
  try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); } catch {}
75
75
  }
76
76
 
77
+ function npm(args) {
78
+ return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args,
79
+ { cwd: WS, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
80
+ }
81
+ // node-pty exec()s a prebuilt macOS `spawn-helper` to open each PTY; the prebuild-extraction path
82
+ // can drop its executable bit, and then EVERY session dies with 'posix_spawnp failed' — the #1
83
+ // field failure. Restore +x wherever node-pty keeps the helper (prebuilds/<plat>-<arch>/ or a
84
+ // from-source build/Release/) before the server starts. Idempotent; no-op off macOS.
85
+ function fixSpawnHelperPerms() {
86
+ if (process.platform === 'win32') return;
87
+ const root = path.join(WS, 'node_modules', 'node-pty');
88
+ const targets = [path.join(root, 'build', 'Release', 'spawn-helper')];
89
+ try { const pb = path.join(root, 'prebuilds'); for (const d of fs.readdirSync(pb)) targets.push(path.join(pb, d, 'spawn-helper')); } catch {}
90
+ for (const f of targets) {
91
+ try { const st = fs.statSync(f); if (!(st.mode & 0o111)) fs.chmodSync(f, st.mode | 0o755); } catch {}
92
+ }
93
+ }
77
94
  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.');
84
- return false;
95
+ if (!fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) {
96
+ say('First run — installing the workspace (one-time, ~20s)…');
97
+ if (!npm(['install', '--no-audit', '--no-fund'])) {
98
+ say('✗ install failed. Run `pmos doctor` for the exact fix.');
99
+ return false;
100
+ }
85
101
  }
102
+ fixSpawnHelperPerms(); // the #1 field failure: a non-executable prebuilt spawn-helper → posix_spawnp
86
103
  return true;
87
104
  }
88
105
 
@@ -109,8 +126,13 @@ async function up(dir) {
109
126
  if (!ensureInstalled()) return 1;
110
127
  fs.mkdirSync(STATE, { recursive: true });
111
128
  const log = fs.openSync(LOGFILE, 'a');
129
+ // Tell the server which repo is HOME. An installed/vendored workspace does not live inside the
130
+ // repo it manages (it's under node_modules or an npx bundle), so the server cannot derive home
131
+ // from its own location — without this it would register the PACKAGE folder as "Home" (the
132
+ // tenant defect). Pass the repo we're opening; a caller-set PMOS_SELF_ROOT still wins.
133
+ const env = { ...process.env, PMOS_SELF_ROOT: process.env.PMOS_SELF_ROOT || repoRootOf(dir) };
112
134
  const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
113
- cwd: WS, detached: true, stdio: ['ignore', log, log], env: process.env,
135
+ cwd: WS, detached: true, stdio: ['ignore', log, log], env,
114
136
  });
115
137
  child.unref();
116
138
  try { fs.writeFileSync(PIDFILE, String(child.pid)); } catch {}
@@ -27,7 +27,11 @@ 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
+ const REINSTALL = `cd "${WSDIR}" && rm -rf node_modules && npm install (reinstalls node-pty for this platform)`;
31
35
 
32
36
  // 1. Node version
33
37
  const major = Number(process.versions.node.split('.')[0]);
@@ -37,29 +41,48 @@ function binaryArch(file) {
37
41
  // 2. node-pty loads
38
42
  let pty = null;
39
43
  try { pty = require('node-pty'); ok('node-pty native module loads'); }
40
- 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`)');
43
- }
44
+ catch (e) { bad(`node-pty failed to load: ${e.message.split('\n')[0]}`, REINSTALL); }
44
45
 
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');
46
+ // 3. native binaries usable — and the #1 field failure, FIXED here. node-pty loads pty.node
47
+ // (from prebuilds/<plat>-<arch>/ or a from-source build/Release/) and, on macOS, exec()s a
48
+ // prebuilt spawn-helper to open each PTY. The prebuild-extraction path can drop that helper's
49
+ // executable bit, so it is present yet every session dies with 'posix_spawnp failed'. This
50
+ // check verifies pty.node's arch and, on macOS, that spawn-helper exists and is +x —
51
+ // RESTORING the bit rather than only reporting it. (Linux uses forkpty directly, no helper.)
52
+ let nproot; try { nproot = path.dirname(require.resolve('node-pty/package.json')); }
53
+ catch { nproot = path.join(__dirname, 'node_modules', 'node-pty'); }
54
+ const findBin = (name) => [
55
+ path.join(nproot, 'build', 'Release', name),
56
+ path.join(nproot, 'prebuilds', `${process.platform}-${process.arch}`, name),
57
+ ].find((f) => fs.existsSync(f));
58
+ const ptyNode = findBin('pty.node');
59
+ let bin3 = true; const note3 = [];
60
+ if (!ptyNode) { bin3 = false; note3.push('pty.node missing (install incomplete)'); }
51
61
  else {
52
- const info = binaryArch(bin);
53
- const want = process.arch; // arm64 | x64 | …
54
- const got = info && info.arch;
55
- const match = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
62
+ const info = binaryArch(ptyNode); const want = process.arch; const got = info && info.arch;
63
+ const archOk = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
56
64
  (want === 'arm64' && got === 'arm64') || (want === 'x64' && got === 'x86_64');
57
- if (match) ok(`native binary present${got && got !== 'universal' && info.x === 'Mach-O' ? ` (${got})` : ''}`);
58
- 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`);
65
+ if (!archOk) { bin3 = false; note3.push(`ARCH MISMATCH (Node ${want} vs binary ${got})`); }
66
+ else if (got && got !== 'universal' && info.x === 'Mach-O') note3.push(got);
67
+ }
68
+ if (bin3 && process.platform === 'darwin') {
69
+ const helper = findBin('spawn-helper');
70
+ if (!helper) { bin3 = false; note3.push('spawn-helper missing'); }
71
+ else {
72
+ const st = fs.statSync(helper);
73
+ if (!(st.mode & 0o111)) {
74
+ try { fs.chmodSync(helper, st.mode | 0o755); note3.push('spawn-helper +x RESTORED — this was the posix_spawnp cause'); }
75
+ catch { bin3 = false; note3.push('spawn-helper not executable'); }
76
+ }
77
+ }
60
78
  }
79
+ if (bin3) ok('node-pty native binaries usable' + (note3.length ? ` (${note3.join('; ')})` : ''));
80
+ else bad('node-pty native binaries unusable' + (note3.length ? ` — ${note3.join('; ')}` : ''),
81
+ note3.some((n) => /MISMATCH/.test(n)) ? `install a ${process.arch}-native Node, then: ${REINSTALL}`
82
+ : note3.some((n) => /not executable/.test(n)) ? `chmod +x "${findBin('spawn-helper')}"`
83
+ : REINSTALL);
61
84
 
62
- // 4. a real PTY spawns and echoes
85
+ // 4. a real PTY spawns and echoes (after the perms fix above this should pass)
63
86
  if (pty) {
64
87
  const sh = process.platform === 'win32' ? (process.env.COMSPEC || 'powershell.exe') : '/bin/sh';
65
88
  try {
@@ -75,7 +98,7 @@ function binaryArch(file) {
75
98
  } catch (e) {
76
99
  bad(`PTY spawn failed: ${e.message}`,
77
100
  /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)'
101
+ ? `on macOS this is node-pty's spawn-helper lacking +x (check 3 restores it — re-run \`npm run doctor\`); otherwise a wrong-arch binary — ${REINSTALL}`
79
102
  : process.platform === 'win32' ? 'native Windows needs WSL for a real PTY — run the workspace inside WSL'
80
103
  : 'check that /bin/sh exists and is executable');
81
104
  }
@@ -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,11 +36,30 @@ 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 the exact cause)');
40
+ console.error(' rm -rf node_modules && npm install (reinstalls node-pty for this platform)');
41
41
  process.exit(1);
42
42
  }
43
43
 
44
+ // node-pty ships a prebuilt macOS `spawn-helper` binary that it exec()s to open each PTY. The
45
+ // prebuild-extraction path can drop its executable bit, and then EVERY session dies with
46
+ // `posix_spawnp failed` — the #1 field failure (it looks like a broken build but the binary is
47
+ // fine, just not +x). Restore the bit wherever node-pty keeps the helper (prebuilds/<plat>-<arch>/
48
+ // or a from-source build/Release/) BEFORE we spawn anything. Cheap, idempotent, no-op off macOS.
49
+ function ensureSpawnHelperExecutable() {
50
+ if (process.platform === 'win32') return;
51
+ try {
52
+ const root = path.dirname(require.resolve('node-pty/package.json'));
53
+ const targets = [path.join(root, 'build', 'Release', 'spawn-helper')];
54
+ const pb = path.join(root, 'prebuilds');
55
+ try { for (const d of fs.readdirSync(pb)) targets.push(path.join(pb, d, 'spawn-helper')); } catch {}
56
+ for (const f of targets) {
57
+ try { const st = fs.statSync(f); if (!(st.mode & 0o111)) fs.chmodSync(f, st.mode | 0o755); } catch {}
58
+ }
59
+ } catch {}
60
+ }
61
+ ensureSpawnHelperExecutable();
62
+
44
63
  const { checkSurface } = require('./lib/surface-contract');
45
64
  const scheduleLogic = require('./lib/schedule-logic');
46
65
  const harness = require('./lib/harness-parse');
@@ -50,7 +69,28 @@ const remoteAccess = require('./lib/remote-access'); // tailnet bind + token g
50
69
  const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
51
70
  const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
52
71
  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
72
+ // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
73
+ // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
74
+ // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
75
+ // there the parent is a PACKAGE dir, not a repo. Deriving the home from __dirname then made the
76
+ // package folder masquerade as the home workspace (the tenant defect: the GUI showed the
77
+ // create-pmos package as "Home" instead of the repo the PM launched). So: the launcher passes
78
+ // PMOS_SELF_ROOT = the repo it opened; that becomes home. Absent the env, we only adopt the
79
+ // parent as home when it genuinely looks like the PMOS repo the app is embedded in. Otherwise
80
+ // there is NO privileged home — every registered repo is equal.
81
+ const PARENT_DIR = path.resolve(__dirname, '..');
82
+ const INSTALLED_COPY = /[\\/]node_modules[\\/]/.test(__dirname); // npm i -g / vendored install
83
+ function dirExists(p) { try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; } }
84
+ const SELF_ROOT = process.env.PMOS_SELF_ROOT ? path.resolve(process.env.PMOS_SELF_ROOT) : PARENT_DIR;
85
+ // EMBEDDED_SELF: the app physically lives inside SELF_ROOT (genuine PMOS-self). Only then is the
86
+ // server's own state/ the file-mode mirror (stateDirFor) — a tenant home must write to its OWN
87
+ // repo's pmos/state, never into this installed package.
88
+ const EMBEDDED_SELF = !INSTALLED_COPY && SELF_ROOT === PARENT_DIR &&
89
+ (fs.existsSync(path.join(PARENT_DIR, 'DECISIONS.md')) || fs.existsSync(path.join(PARENT_DIR, 'AGENTS.md')));
90
+ // HOME_ENABLED: is there a privileged home workspace at all? Yes when embedded, or when the
91
+ // launcher named a real repo via PMOS_SELF_ROOT. An installed copy launched WITHOUT that env
92
+ // (unsupported direct run) has no home — the client must open a repo to do anything.
93
+ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists(SELF_ROOT));
54
94
  const PUB = path.join(__dirname, 'public');
55
95
  const XROOT = path.join(__dirname, 'node_modules', '@xterm');
56
96
  const STATE = path.join(__dirname, 'state');
@@ -149,16 +189,21 @@ function registerWorkspace(root, name) {
149
189
  // while it still owns sessions, so scrollback is never orphaned by a click.
150
190
  function forgetWorkspace(id) {
151
191
  const w = workspaces.get(id);
152
- if (!w || w.root === SELF_ROOT) return false;
192
+ if (!w || (HOME_ENABLED && w.root === SELF_ROOT)) return false;
153
193
  if ([...sessions.values()].some((s) => s.ws === id)) return false;
154
194
  workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
155
195
  return true;
156
196
  }
157
- function homeWorkspace() { return workspaces.get(wsId(SELF_ROOT)); }
197
+ // The home is SELF_ROOT when enabled; otherwise (installed copy, no launcher env) there is no
198
+ // privileged home, so an unresolved ws id falls back to the first registered repo rather than
199
+ // resolving to the package dir.
200
+ function homeWorkspace() {
201
+ return HOME_ENABLED ? workspaces.get(wsId(SELF_ROOT)) : [...workspaces.values()][0];
202
+ }
158
203
  function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
159
204
  function wsList() {
160
205
  return [...workspaces.values()].map((w) => ({
161
- id: w.id, name: w.name, root: w.root, home: w.root === SELF_ROOT,
206
+ id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
162
207
  shape: wsShape(w.root), addedAt: w.addedAt,
163
208
  sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
164
209
  }));
@@ -792,15 +837,18 @@ function appendBuf(s, data) {
792
837
  s.parsed = null; // buffer moved; the cached parse is stale
793
838
  }
794
839
 
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.
840
+ // Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
841
+ // node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
842
+ // the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
843
+ // re-applies + verifies the fix; a wrong-arch binary (x64 Node on Apple Silicon) is the rarer
844
+ // alternative, cured by a clean reinstall.
798
845
  function spawnFailureAdvice(err, cmd, isShell) {
799
846
  const msg = String(err && err.message || err);
800
847
  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\`.`;
848
+ return `could not spawn '${cmd}' (${msg}). On macOS this is almost always node-pty's prebuilt ` +
849
+ `spawn-helper lacking its executable bit. Fix: run \`pmos doctor\` (it restores +x and verifies), ` +
850
+ `then ↻ respawn. If it persists, the binary is wrong-arch — \`rm -rf node_modules && npm install\` ` +
851
+ `in the workspace install dir.`;
804
852
  }
805
853
  if (process.platform === 'win32') {
806
854
  return `could not start '${cmd}': ${msg}. Native Windows needs WSL for a real PTY shell — ` +
@@ -873,8 +921,21 @@ function ensureTmuxSession(name, cmd, workdir, cols, rows) {
873
921
  function attachPty(s) {
874
922
  const name = tmuxName(s.id);
875
923
  const startedMs = Date.now();
876
- const p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
877
- { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
924
+ let p;
925
+ try {
926
+ p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
927
+ { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
928
+ } catch (e) {
929
+ // A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
930
+ // NOT propagate: on the startup first-shell it would crash the whole server and blank the
931
+ // entire UI ("no terminal painted"). Degrade to an honest exited pane carrying the fix, exactly
932
+ // like the direct-PTY path does — the server stays up and the session shows why + a respawn.
933
+ s.pty = null; s.state = 'exited';
934
+ s.note = spawnFailureAdvice(e, s.agent || 'session', false);
935
+ appendBuf(s, `\x1b[38;5;203m✗ ${s.note}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`);
936
+ persist(s); pushSessions();
937
+ return null;
938
+ }
878
939
  s.pty = p; s.tmux = name; s.state = 'working';
879
940
  // Start the quiet clock at ATTACH, not at first byte: a session that spawns and immediately
880
941
  // hangs should go quiet on schedule rather than read 'working' forever. On a restore this
@@ -1036,7 +1097,7 @@ function stateDirFor(w, { create = false } = {}) {
1036
1097
  const d = path.join(w.root, ...rel);
1037
1098
  if (isDir(d)) return d;
1038
1099
  }
1039
- if (w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror
1100
+ 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
1101
  // A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
1041
1102
  // invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
1042
1103
  if (create && wsShape(w.root) === 'pmos') {
@@ -1353,7 +1414,7 @@ async function handleRequest(req, res) {
1353
1414
  // tell any open client to switch — otherwise the browser refocuses the old tab and the
1354
1415
  // PM sees the previous repo, which is precisely the reported defect
1355
1416
  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) } });
1417
+ return json(res, { ok: true, workspace: { ...w, home: HOME_ENABLED && w.root === SELF_ROOT, shape: wsShape(w.root) } });
1357
1418
  }
1358
1419
  if (p === '/api/workspaces/forget' && req.method === 'POST') {
1359
1420
  let body = {};
@@ -1603,12 +1664,29 @@ function httpGuard(req, res) {
1603
1664
  res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
1604
1665
  return;
1605
1666
  }
1667
+ // Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
1668
+ // browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
1669
+ // sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
1670
+ // phone flow (REMOTE.md + startup banner) must reach THIS page-load guard, and every later request
1671
+ // rides the cookie we seed just below. presentedToken() resolves all channels. (local hotfix)
1606
1672
  if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
1607
- presentedToken: remoteAccess.extractHttpToken(req.headers), expectedToken: TOKEN })) {
1673
+ presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
1608
1674
  res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' });
1609
1675
  res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
1610
1676
  return;
1611
1677
  }
1678
+ // Seed a same-origin session cookie when a VALID token arrived via the ?token= query on a non-loopback
1679
+ // navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
1680
+ // in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
1681
+ // foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
1682
+ if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress)) {
1683
+ let qTok = null;
1684
+ try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
1685
+ if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
1686
+ res.setHeader('Set-Cookie',
1687
+ `${remoteAccess.COOKIE_NAME}=${encodeURIComponent(TOKEN)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800`);
1688
+ }
1689
+ }
1612
1690
  return handleRequest(req, res);
1613
1691
  }
1614
1692
  const server = http.createServer(httpGuard);
@@ -1626,8 +1704,10 @@ function handleUpgrade(req, socket, head) {
1626
1704
  socket.destroy();
1627
1705
  return;
1628
1706
  }
1707
+ // A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
1708
+ // seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
1629
1709
  if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress,
1630
- presentedToken: remoteAccess.extractWsToken(req), expectedToken: TOKEN })) {
1710
+ presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
1631
1711
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
1632
1712
  socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
1633
1713
  return;
@@ -1644,7 +1724,7 @@ wss.on('connection', (ws) => {
1644
1724
  // dropped silently; excess spawns get an honest error rather than piling up shells.
1645
1725
  ws._rlSpawn = rateLimit.createBucket(8, 0.5);
1646
1726
  ws._rlInput = rateLimit.createBucket(400, 200);
1647
- ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: wsId(SELF_ROOT), sessions: sessionList() }));
1727
+ ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: HOME_ENABLED ? wsId(SELF_ROOT) : null, sessions: sessionList() }));
1648
1728
 
1649
1729
  ws.on('message', (raw) => {
1650
1730
  let m; try { m = JSON.parse(raw); } catch { return; }
@@ -1693,12 +1773,14 @@ wss.on('connection', (ws) => {
1693
1773
  });
1694
1774
 
1695
1775
  loadWorkspaces();
1696
- registerWorkspace(SELF_ROOT); // the home workspace is always hosted
1776
+ if (HOME_ENABLED) registerWorkspace(SELF_ROOT); // the home workspace is hosted (PMOS-self, or the repo the launcher named)
1697
1777
  writeTmuxConf(); // must exist before restore may reattach or spawn creates
1698
1778
  restorePersisted();
1699
1779
 
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) {
1780
+ // A first shell so the workspace is never empty on first run (honest real PTY). Only when there
1781
+ // is a repo to open it IN: an installed copy with no home and no registered repo yet must wait
1782
+ // for the launcher to POST a workspace rather than spawn a shell in the package directory.
1783
+ if ((HOME_ENABLED || workspaces.size) && [...sessions.values()].filter((s) => s.pty).length === 0) {
1702
1784
  spawnSession({ name: 'shell', agent: 'shell' });
1703
1785
  }
1704
1786