create-pmos 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/bin/pmos.js +14 -25
- package/workspace/doctor.js +42 -38
- package/workspace/lib/git-info.js +71 -0
- package/workspace/public/app.js +137 -4
- package/workspace/public/index.html +25 -0
- package/workspace/public/styles.css +37 -0
- package/workspace/server.js +104 -18
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "create-pmos"
|
|
4
|
-
timestamp: 2026-07-
|
|
4
|
+
timestamp: 2026-07-27
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# create-pmos
|
|
@@ -333,6 +333,22 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.6.4
|
|
337
|
+
|
|
338
|
+
Workspace GUI session controls — two things the PM asked for while working in real terminals.
|
|
339
|
+
|
|
340
|
+
- **Branch awareness + pick/create at session start.** The header now shows the active repo's current
|
|
341
|
+
git branch (dirty-marked), and the new-session form lets you start a session on an existing branch
|
|
342
|
+
or create a new one. It's honest about the shared working tree: switching a branch is repo-wide and
|
|
343
|
+
moves every session in that repo — the form says so rather than faking per-session isolation.
|
|
344
|
+
- **Scratchpad.** A collapsible drawer at the bottom of the terminal column (toggle from the header or
|
|
345
|
+
⌘J) to stage commands and notes before they hit the terminal — it never overlays the terminal or the
|
|
346
|
+
document reader, persists per workspace, and has **copy** and **→ terminal** (which pastes without
|
|
347
|
+
pressing enter, so nothing auto-runs).
|
|
348
|
+
|
|
349
|
+
**Upgrade note:** the workspace app ships inside the package; `npx create-pmos@latest workspace`
|
|
350
|
+
installs the new build. Your `state/`, `planning/`, and `okf/` records are untouched — purely additive.
|
|
351
|
+
|
|
336
352
|
## What's new in 0.6.1
|
|
337
353
|
|
|
338
354
|
A completeness fix: two skills the kit already *pointed at* but didn't *ship*.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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
package/workspace/bin/pmos.js
CHANGED
|
@@ -74,20 +74,23 @@ 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
77
|
function npm(args) {
|
|
88
78
|
return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args,
|
|
89
79
|
{ cwd: WS, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
|
|
90
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
|
+
}
|
|
91
94
|
function ensureInstalled() {
|
|
92
95
|
if (!fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) {
|
|
93
96
|
say('First run — installing the workspace (one-time, ~20s)…');
|
|
@@ -96,21 +99,7 @@ function ensureInstalled() {
|
|
|
96
99
|
return false;
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
|
-
//
|
|
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).`);
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
102
|
+
fixSpawnHelperPerms(); // the #1 field failure: a non-executable prebuilt spawn-helper → posix_spawnp
|
|
114
103
|
return true;
|
|
115
104
|
}
|
|
116
105
|
|
package/workspace/doctor.js
CHANGED
|
@@ -31,54 +31,58 @@ function binaryArch(file) {
|
|
|
31
31
|
console.log(`pmos workspace doctor — node ${process.version} (${process.platform}/${process.arch})`);
|
|
32
32
|
console.log(` install dir: ${WSDIR}\n`);
|
|
33
33
|
|
|
34
|
-
|
|
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.`;
|
|
34
|
+
const REINSTALL = `cd "${WSDIR}" && rm -rf node_modules && npm install (reinstalls node-pty for this platform)`;
|
|
43
35
|
|
|
44
36
|
// 1. Node version
|
|
45
37
|
const major = Number(process.versions.node.split('.')[0]);
|
|
46
|
-
if (major
|
|
47
|
-
else
|
|
48
|
-
else ok(`Node ${process.version} (>= 18)`);
|
|
38
|
+
if (major >= 18) ok(`Node ${process.version} (>= 18)`);
|
|
39
|
+
else bad(`Node ${process.version} is too old`, 'install Node 18+ (arm64-native on Apple Silicon)');
|
|
49
40
|
|
|
50
41
|
// 2. node-pty loads
|
|
51
42
|
let pty = null;
|
|
52
43
|
try { pty = require('node-pty'); ok('node-pty native module loads'); }
|
|
53
|
-
catch (e) {
|
|
54
|
-
bad(`node-pty failed to load: ${e.message.split('\n')[0]}`, REINSTALL_LTS);
|
|
55
|
-
}
|
|
44
|
+
catch (e) { bad(`node-pty failed to load: ${e.message.split('\n')[0]}`, REINSTALL); }
|
|
56
45
|
|
|
57
|
-
// 3.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const got = info && info.arch;
|
|
74
|
-
const
|
|
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)'); }
|
|
61
|
+
else {
|
|
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' ||
|
|
75
64
|
(want === 'arm64' && got === 'arm64') || (want === 'x64' && got === 'x86_64');
|
|
76
|
-
if (
|
|
77
|
-
else
|
|
78
|
-
|
|
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
|
+
}
|
|
79
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);
|
|
80
84
|
|
|
81
|
-
// 4. a real PTY spawns and echoes
|
|
85
|
+
// 4. a real PTY spawns and echoes (after the perms fix above this should pass)
|
|
82
86
|
if (pty) {
|
|
83
87
|
const sh = process.platform === 'win32' ? (process.env.COMSPEC || 'powershell.exe') : '/bin/sh';
|
|
84
88
|
try {
|
|
@@ -94,7 +98,7 @@ function binaryArch(file) {
|
|
|
94
98
|
} catch (e) {
|
|
95
99
|
bad(`PTY spawn failed: ${e.message}`,
|
|
96
100
|
/posix_spawnp/.test(e.message)
|
|
97
|
-
? `
|
|
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}`
|
|
98
102
|
: process.platform === 'win32' ? 'native Windows needs WSL for a real PTY — run the workspace inside WSL'
|
|
99
103
|
: 'check that /bin/sh exists and is executable');
|
|
100
104
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Read-mostly git helpers for the workspace (branch awareness + select/create at session start).
|
|
3
|
+
// Everything here shells `git` via spawnSync with an ARGS ARRAY — never a shell string — so no
|
|
4
|
+
// caller input is ever interpolated into a command line. A workspace's working tree is SHARED by
|
|
5
|
+
// every session in it (D62), so a branch is a property of the REPO, not a session: switching moves
|
|
6
|
+
// every session in that repo. Callers surface that truth; this module never pretends otherwise.
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const GIT_TIMEOUT = 5000; // never let a slow/hung repo (huge tree, network remote) block the server
|
|
12
|
+
|
|
13
|
+
function isRepo(root) {
|
|
14
|
+
// a normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
|
|
15
|
+
try { return !!root && fs.existsSync(path.join(root, '.git')); } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function git(root, args) {
|
|
19
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT });
|
|
20
|
+
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), failed: !!r.error };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// git check-ref-format for a BRANCH name, re-implemented defensively so nothing dubious ever
|
|
24
|
+
// reaches `git` (defense in depth — `git` itself is the final authority and rejects the rest).
|
|
25
|
+
// Rejects: empty, whitespace/control, the special chars ~ ^ : ? * [ \ , the sequences .. and @{,
|
|
26
|
+
// a leading '-' (would be read as a flag), leading/trailing '/' or '.', doubled '/', a trailing
|
|
27
|
+
// '.lock', and the lone '@'. Allows the ordinary set A–Z a–z 0–9 . _ / -, e.g. initiative/foo-bar.
|
|
28
|
+
function validBranchName(name) {
|
|
29
|
+
if (typeof name !== 'string') return false;
|
|
30
|
+
const n = name.trim();
|
|
31
|
+
if (!n || n.length > 200) return false;
|
|
32
|
+
if (/[\x00-\x20~^:?*[\\\s]/.test(n)) return false; // control/space + git's forbidden set
|
|
33
|
+
if (n.includes('..') || n.includes('@{') || n.includes('//')) return false;
|
|
34
|
+
if (n === '@') return false;
|
|
35
|
+
if (n.startsWith('-') || n.startsWith('/') || n.startsWith('.')) return false;
|
|
36
|
+
if (n.endsWith('/') || n.endsWith('.') || n.endsWith('.lock')) return false;
|
|
37
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(n)) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// { ok, current, branches[], detached, dirty }. A non-repo or a failed git returns ok:false with a
|
|
42
|
+
// reason — NEVER a fabricated branch (a wrong branch chip is worse than none; anti-drift).
|
|
43
|
+
function branchInfo(root) {
|
|
44
|
+
if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
|
|
45
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
46
|
+
if (head.failed) return { ok: false, error: 'git not available' };
|
|
47
|
+
if (head.code !== 0) return { ok: false, error: head.err || 'could not read HEAD' };
|
|
48
|
+
const detached = head.out === 'HEAD';
|
|
49
|
+
const current = detached ? null : head.out;
|
|
50
|
+
const refs = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
|
|
51
|
+
const branches = refs.code === 0 && refs.out ? refs.out.split('\n').filter(Boolean) : [];
|
|
52
|
+
const status = git(root, ['status', '--porcelain']);
|
|
53
|
+
const dirty = status.code === 0 ? status.out.length > 0 : false;
|
|
54
|
+
return { ok: true, current, branches, detached, dirty };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check out an existing branch, or create one with -b. Confined to `root` by cwd; the name is
|
|
58
|
+
// validated first so nothing malformed reaches git. On any failure returns { ok:false, error } and
|
|
59
|
+
// leaves the caller to NOT spawn a session on the wrong branch (a failed checkout must never spawn).
|
|
60
|
+
function checkout(root, branch, { create = false } = {}) {
|
|
61
|
+
if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
|
|
62
|
+
if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
|
|
63
|
+
const args = create ? ['checkout', '-b', branch, '--'] : ['checkout', branch, '--'];
|
|
64
|
+
const r = git(root, args);
|
|
65
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
66
|
+
if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'checkout failed').split('\n')[0].slice(0, 200) };
|
|
67
|
+
const info = branchInfo(root);
|
|
68
|
+
return { ok: true, current: info.ok ? info.current : branch };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { isRepo, validBranchName, branchInfo, checkout };
|
package/workspace/public/app.js
CHANGED
|
@@ -31,6 +31,8 @@ const state = {
|
|
|
31
31
|
docPath: null, // the document open in the reader
|
|
32
32
|
fsDir: null, // the in-app folder browser's current directory
|
|
33
33
|
ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
|
|
34
|
+
branch: null, // {ok,current,branches[],detached,dirty} for the selected workspace
|
|
35
|
+
newBranchMode: false, // new-session form: creating a branch vs picking an existing one
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
// ── workspace state survives reloads (R7): theme + selected workspace + focused session,
|
|
@@ -121,9 +123,37 @@ function handle(m) {
|
|
|
121
123
|
} else if (m.t === 'created') {
|
|
122
124
|
// server pushes a fresh 'sessions'; jump to the new one
|
|
123
125
|
selectSession(m.id);
|
|
126
|
+
} else if (m.t === 'branch') {
|
|
127
|
+
// a checkout changed the repo's branch — refresh the chip if it's the workspace on screen
|
|
128
|
+
if (m.ws === state.wsId) { state.branch = m; renderBranch(); }
|
|
129
|
+
} else if (m.t === 'error') {
|
|
130
|
+
// a rejected action (e.g. a failed branch checkout that must NOT spawn) — say so, don't swallow
|
|
131
|
+
flash(m.error || 'the server rejected that action');
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
// ── branch awareness (R2): the active workspace's current git branch, always visible. Fetched on
|
|
136
|
+
// demand (never on every broadcast) — on workspace switch, on opening the new-session form, and
|
|
137
|
+
// after a checkout the server announces. A non-repo/unreadable workspace shows NO chip, never a
|
|
138
|
+
// stale or guessed branch. ──
|
|
139
|
+
async function loadBranch() {
|
|
140
|
+
const id = state.wsId;
|
|
141
|
+
let info = { ok: false };
|
|
142
|
+
try { const r = await fetch('/api/git/branches?ws=' + encodeURIComponent(id || '')); info = await r.json(); } catch {}
|
|
143
|
+
if (id !== state.wsId) return; // a slower fetch for a workspace we've since left must not paint
|
|
144
|
+
state.branch = info; renderBranch();
|
|
145
|
+
}
|
|
146
|
+
function renderBranch() {
|
|
147
|
+
const el = $('branchChip'); if (!el) return;
|
|
148
|
+
const b = state.branch;
|
|
149
|
+
if (!b || !b.ok) { el.hidden = true; el.textContent = ''; el.title = ''; return; }
|
|
150
|
+
const label = b.detached ? 'detached HEAD' : (b.current || '');
|
|
151
|
+
el.hidden = !label;
|
|
152
|
+
el.textContent = '⎇ ' + label + (b.dirty ? '*' : '');
|
|
153
|
+
el.title = b.dirty ? 'uncommitted changes in the working tree' : 'current branch (shared by every session in this repo)';
|
|
154
|
+
el.classList.toggle('dirty', !!b.dirty);
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
// ── workspaces ──
|
|
128
158
|
async function selectWorkspace(id, opts = {}) {
|
|
129
159
|
if (!id) return;
|
|
@@ -138,7 +168,8 @@ async function selectWorkspace(id, opts = {}) {
|
|
|
138
168
|
const remembered = opts.restoreSession ? store.get('active') : null;
|
|
139
169
|
const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
|
|
140
170
|
if (pick) selectSession(pick.id); else clearActive();
|
|
141
|
-
|
|
171
|
+
if (changed) { state.branch = null; renderBranch(); loadScratchpad(); }
|
|
172
|
+
await Promise.all([loadLibrary(), loadNeeds(), loadBranch()]);
|
|
142
173
|
}
|
|
143
174
|
function renderWorkspaces() {
|
|
144
175
|
const html = state.workspaces.map((w) => {
|
|
@@ -385,6 +416,7 @@ function syncActiveState() {
|
|
|
385
416
|
if (s) b.dataset.state = s.state;
|
|
386
417
|
});
|
|
387
418
|
updateExitedOverlay();
|
|
419
|
+
refreshScratchSend(); // the scratchpad's send button follows the active session's liveness
|
|
388
420
|
}
|
|
389
421
|
|
|
390
422
|
// ── renaming a session (R11): inline, in both surfaces, live or exited. The name chosen at
|
|
@@ -482,7 +514,7 @@ function selectSession(id) {
|
|
|
482
514
|
const s = state.sessions.find((x) => x.id === id);
|
|
483
515
|
if (!s) return;
|
|
484
516
|
// selecting a session from the palette may cross workspaces — follow it there
|
|
485
|
-
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); }
|
|
517
|
+
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadScratchpad(); }
|
|
486
518
|
state.active = id;
|
|
487
519
|
store.set('active', id); // survives reloads (R7)
|
|
488
520
|
// attach once per connection — re-selecting an attached session must not reset + re-replay
|
|
@@ -512,6 +544,7 @@ function updateExitedOverlay() {
|
|
|
512
544
|
$('respawnBtn').onclick = () => send({ t: 'respawn', id: s.id });
|
|
513
545
|
$('removeBtn').onclick = () => send({ t: 'remove', id: s.id });
|
|
514
546
|
} else ov.classList.remove('on');
|
|
547
|
+
refreshScratchSend(); // send-to-terminal follows whether the active session is live
|
|
515
548
|
}
|
|
516
549
|
function updateEmptyOverlay() {
|
|
517
550
|
// a workspace with no sessions must say so — not show another workspace's terminal
|
|
@@ -1062,7 +1095,7 @@ function resolveDocPath(ctx, u) {
|
|
|
1062
1095
|
return stack.join('/');
|
|
1063
1096
|
}
|
|
1064
1097
|
|
|
1065
|
-
// ── new session: name it before it starts ──
|
|
1098
|
+
// ── new session: name it, pick its branch, before it starts ──
|
|
1066
1099
|
function openNewForm() {
|
|
1067
1100
|
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
1068
1101
|
$('newForm').hidden = false;
|
|
@@ -1070,6 +1103,7 @@ function openNewForm() {
|
|
|
1070
1103
|
// pre-filled but editable — a default that reads better than "shell" in a list of shells
|
|
1071
1104
|
const n = mySessions().length + 1;
|
|
1072
1105
|
$('newName').value = `${w ? w.name : 'session'} ${n}`;
|
|
1106
|
+
fillBranchPicker(); // populate from the workspace's live branch info
|
|
1073
1107
|
const inp = $('newName');
|
|
1074
1108
|
setTimeout(() => { inp.focus(); inp.select(); }, 0);
|
|
1075
1109
|
}
|
|
@@ -1077,8 +1111,29 @@ function closeNewForm() {
|
|
|
1077
1111
|
$('newForm').hidden = true;
|
|
1078
1112
|
$('newBtn').setAttribute('aria-expanded', 'false');
|
|
1079
1113
|
}
|
|
1114
|
+
// Populate the branch control from state.branch. A non-repo workspace hides the whole control —
|
|
1115
|
+
// there is no honest branch to pick. The default is "stay on the current branch" (no checkout).
|
|
1116
|
+
function fillBranchPicker() {
|
|
1117
|
+
const wrap = $('newBranchWrap'), sel = $('newBranch');
|
|
1118
|
+
const b = state.branch;
|
|
1119
|
+
if (!b || !b.ok) { wrap.hidden = true; return; }
|
|
1120
|
+
wrap.hidden = false;
|
|
1121
|
+
const cur = b.detached ? '' : (b.current || '');
|
|
1122
|
+
const opts = [`<option value="">stay on current${cur ? ' (' + esc(cur) + ')' : ''}</option>`]
|
|
1123
|
+
.concat((b.branches || []).map((n) => `<option value="${esc(n)}"${n === cur ? ' disabled' : ''}>${esc(n)}${n === cur ? ' — current' : ''}</option>`))
|
|
1124
|
+
.concat([`<option value="__new__">+ new branch…</option>`]);
|
|
1125
|
+
sel.innerHTML = opts.join('');
|
|
1126
|
+
sel.value = '';
|
|
1127
|
+
setNewBranchMode(false);
|
|
1128
|
+
}
|
|
1129
|
+
function setNewBranchMode(on) {
|
|
1130
|
+
state.newBranchMode = on;
|
|
1131
|
+
$('newBranchName').hidden = !on;
|
|
1132
|
+
if (on) setTimeout(() => $('newBranchName').focus(), 0);
|
|
1133
|
+
}
|
|
1080
1134
|
$('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
|
|
1081
1135
|
$('newCancel').onclick = closeNewForm;
|
|
1136
|
+
$('newBranch').onchange = (e) => setNewBranchMode(e.target.value === '__new__');
|
|
1082
1137
|
$('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
1083
1138
|
state.newAgent = b.dataset.agent;
|
|
1084
1139
|
$('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
|
|
@@ -1086,10 +1141,85 @@ $('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
|
1086
1141
|
$('newForm').addEventListener('submit', (e) => {
|
|
1087
1142
|
e.preventDefault();
|
|
1088
1143
|
const name = $('newName').value.trim();
|
|
1089
|
-
|
|
1144
|
+
const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
|
|
1145
|
+
// branch: either create a new one, or check out an existing pick. Empty = stay put (no checkout).
|
|
1146
|
+
const sel = $('newBranch');
|
|
1147
|
+
if (!$('newBranchWrap').hidden && sel) {
|
|
1148
|
+
if (sel.value === '__new__') {
|
|
1149
|
+
const nb = $('newBranchName').value.trim();
|
|
1150
|
+
if (!nb) { flash('type a name for the new branch, or pick "stay on current"'); return; }
|
|
1151
|
+
msg.branch = nb; msg.createBranch = true;
|
|
1152
|
+
} else if (sel.value) {
|
|
1153
|
+
msg.branch = sel.value;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
send(msg);
|
|
1090
1157
|
closeNewForm();
|
|
1091
1158
|
});
|
|
1092
1159
|
|
|
1160
|
+
// ── scratchpad (R4): per-workspace staging text; copy or send-to-terminal without auto-running ──
|
|
1161
|
+
let scratchTimer = null;
|
|
1162
|
+
function setScratchStatus(s) { const el = $('scratchStatus'); if (el) el.textContent = s; }
|
|
1163
|
+
async function loadScratchpad() {
|
|
1164
|
+
const id = state.wsId;
|
|
1165
|
+
let text = '';
|
|
1166
|
+
try { const r = await fetch('/api/scratchpad?ws=' + encodeURIComponent(id || '')); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
|
|
1167
|
+
if (id !== state.wsId) return; // a slower fetch for a workspace we've left must not paint
|
|
1168
|
+
$('scratchText').value = text; setScratchStatus('');
|
|
1169
|
+
}
|
|
1170
|
+
function saveScratchpad() {
|
|
1171
|
+
const ws = state.wsId, text = $('scratchText').value;
|
|
1172
|
+
clearTimeout(scratchTimer);
|
|
1173
|
+
scratchTimer = setTimeout(async () => {
|
|
1174
|
+
setScratchStatus('saving…');
|
|
1175
|
+
try {
|
|
1176
|
+
const r = await fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ws, text }) });
|
|
1177
|
+
const j = await r.json();
|
|
1178
|
+
setScratchStatus(j && j.ok ? 'saved' : 'save failed'); // a broken save is never a silent green
|
|
1179
|
+
} catch { setScratchStatus('save failed'); }
|
|
1180
|
+
}, 500);
|
|
1181
|
+
}
|
|
1182
|
+
// Open/close the drawer. It lives in the flex column below the terminal, so toggling it resizes
|
|
1183
|
+
// termhost — the existing ResizeObserver refits the terminal; nothing is ever covered.
|
|
1184
|
+
function openScratch(on) {
|
|
1185
|
+
const el = $('scratch');
|
|
1186
|
+
const open = on == null ? el.dataset.open !== 'true' : !!on;
|
|
1187
|
+
el.dataset.open = String(open);
|
|
1188
|
+
$('scratchToggle').setAttribute('aria-expanded', String(open));
|
|
1189
|
+
$('scratchBtn').setAttribute('aria-pressed', String(open));
|
|
1190
|
+
store.set('scratch-open', open ? '1' : '0');
|
|
1191
|
+
if (open) setTimeout(() => $('scratchText').focus(), 0);
|
|
1192
|
+
}
|
|
1193
|
+
// send-to-terminal writes the text into the active session's PTY WITHOUT a trailing newline — the
|
|
1194
|
+
// PM reviews and presses enter. Disabled when there is no live session to receive it.
|
|
1195
|
+
function activeLiveSession() {
|
|
1196
|
+
const s = state.sessions.find((x) => x.id === state.active);
|
|
1197
|
+
return s && s.state !== 'exited' ? s : null;
|
|
1198
|
+
}
|
|
1199
|
+
function refreshScratchSend() {
|
|
1200
|
+
const btn = $('scratchSend'); if (!btn) return;
|
|
1201
|
+
const live = !!activeLiveSession();
|
|
1202
|
+
btn.disabled = !live;
|
|
1203
|
+
btn.title = live ? 'Paste into the active terminal (does not press enter)' : 'no live session to send to';
|
|
1204
|
+
}
|
|
1205
|
+
$('scratchToggle').onclick = () => openScratch();
|
|
1206
|
+
$('scratchBtn').onclick = () => openScratch();
|
|
1207
|
+
$('scratchText').addEventListener('input', saveScratchpad);
|
|
1208
|
+
$('scratchCopy').onclick = async () => {
|
|
1209
|
+
const text = $('scratchText').value;
|
|
1210
|
+
try { await navigator.clipboard.writeText(text); setScratchStatus('copied'); }
|
|
1211
|
+
catch { $('scratchText').select(); setScratchStatus('press ⌘C to copy'); }
|
|
1212
|
+
};
|
|
1213
|
+
$('scratchSend').onclick = () => {
|
|
1214
|
+
const s = activeLiveSession();
|
|
1215
|
+
if (!s) { flash('no live session to send to — start or select one'); return; }
|
|
1216
|
+
const text = $('scratchText').value;
|
|
1217
|
+
if (!text) return;
|
|
1218
|
+
send({ t: 'input', id: s.id, data: text }); // no trailing newline: nothing auto-runs
|
|
1219
|
+
setScratchStatus('sent → terminal');
|
|
1220
|
+
};
|
|
1221
|
+
{ const open = store.get('scratch-open') === '1'; if (open) openScratch(true); }
|
|
1222
|
+
|
|
1093
1223
|
// ── add a workspace: pick a folder (the GUI twin of running `pmos` in that repo) ──
|
|
1094
1224
|
function closeAddWs() {
|
|
1095
1225
|
$('addWsForm').hidden = true; $('addWsBtn').setAttribute('aria-expanded', 'false'); $('addWsPath').value = '';
|
|
@@ -1657,6 +1787,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
1657
1787
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
1658
1788
|
e.preventDefault(); e.stopPropagation(); togglePalette(); return;
|
|
1659
1789
|
}
|
|
1790
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
|
|
1791
|
+
e.preventDefault(); e.stopPropagation(); openScratch(); return;
|
|
1792
|
+
}
|
|
1660
1793
|
if (e.key === 'F2' && state.active && !state.renaming) { e.preventDefault(); startRename(state.active); return; }
|
|
1661
1794
|
if (e.key === 'Escape') {
|
|
1662
1795
|
if ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
<header class="bar">
|
|
14
14
|
<span class="mark">P</span><b>PMOS</b><span class="sub">workspace</span>
|
|
15
15
|
<span class="conn" id="conn">connecting…</span>
|
|
16
|
+
<span class="branch" id="branchChip" hidden title="current branch"></span>
|
|
16
17
|
<div class="right">
|
|
18
|
+
<button class="ibtn" id="scratchBtn" title="Scratchpad — stage text before the terminal (⌘J)" aria-pressed="false">scratch</button>
|
|
17
19
|
<button class="ibtn" id="railBtn" title="Hide/show the library rail" aria-pressed="false">rail</button>
|
|
18
20
|
<button class="ibtn" id="themeBtn" title="Toggle theme">theme</button>
|
|
19
21
|
</div>
|
|
@@ -53,6 +55,12 @@
|
|
|
53
55
|
<button type="button" data-agent="claude" role="radio" aria-checked="false">claude</button>
|
|
54
56
|
<button type="button" data-agent="codex" role="radio" aria-checked="false">codex</button>
|
|
55
57
|
</div>
|
|
58
|
+
<div class="brwrap" id="newBranchWrap" hidden>
|
|
59
|
+
<label class="lbl" for="newBranch">branch</label>
|
|
60
|
+
<select id="newBranch" aria-label="Branch to run this session on"></select>
|
|
61
|
+
<input id="newBranchName" type="text" hidden placeholder="new-branch-name" aria-label="New branch name" autocomplete="off" spellcheck="false" />
|
|
62
|
+
<p class="hint warn">the branch is shared by every session in this repo — switching or creating one moves them all</p>
|
|
63
|
+
</div>
|
|
56
64
|
<div class="row">
|
|
57
65
|
<button type="submit" class="go">start session</button>
|
|
58
66
|
<button type="button" class="quiet" id="newCancel">cancel</button>
|
|
@@ -85,6 +93,23 @@
|
|
|
85
93
|
</div>
|
|
86
94
|
</div>
|
|
87
95
|
<div class="needs" id="needs" role="status" style="display:none"></div>
|
|
96
|
+
|
|
97
|
+
<!-- Scratchpad: a collapsible drawer at the BOTTOM of the terminal column. Folded it is a thin
|
|
98
|
+
bar; open it takes a bounded slice below the terminal — it never overlays the tabs, the
|
|
99
|
+
terminal, or the document rail. Persists per workspace. -->
|
|
100
|
+
<section class="scratch" id="scratch" data-open="false" aria-label="Scratchpad">
|
|
101
|
+
<div class="scratch-head">
|
|
102
|
+
<button type="button" class="scratch-toggle" id="scratchToggle" aria-expanded="false" aria-controls="scratchText">
|
|
103
|
+
<span class="chev" aria-hidden="true">▴</span> scratchpad
|
|
104
|
+
</button>
|
|
105
|
+
<span class="scratch-status" id="scratchStatus" role="status"></span>
|
|
106
|
+
<span class="scratch-actions">
|
|
107
|
+
<button type="button" class="mini" id="scratchCopy" title="Copy to clipboard">copy</button>
|
|
108
|
+
<button type="button" class="mini" id="scratchSend" title="Paste into the active terminal (does not press enter)">→ terminal</button>
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
<textarea id="scratchText" class="scratch-text" placeholder="Stage commands or notes here, then copy or send them to the terminal…" spellcheck="false" autocomplete="off" aria-label="Scratchpad text"></textarea>
|
|
112
|
+
</section>
|
|
88
113
|
</main>
|
|
89
114
|
|
|
90
115
|
<aside class="rail" id="rail" aria-label="Library and document">
|
|
@@ -773,3 +773,40 @@ details.tree>summary:hover{color:var(--text-2)}
|
|
|
773
773
|
.cs-perrow .bar{flex:1;height:6px;border-radius:4px;background:var(--chip);overflow:hidden}
|
|
774
774
|
.cs-perrow .bar i{display:block;height:100%;background:var(--accent);border-radius:4px}
|
|
775
775
|
.cs-perrow .v{font-family:var(--mono);font-size:10px;color:var(--text-dim);flex:none;width:28px;text-align:right}
|
|
776
|
+
|
|
777
|
+
/* ── branch awareness (workspace-session-controls) ── */
|
|
778
|
+
.bar .branch{font-family:var(--mono);font-size:10px;letter-spacing:.04em;color:var(--text-3);
|
|
779
|
+
padding:3px 8px;border:1px solid var(--border);border-radius:7px;background:var(--chip);
|
|
780
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:26ch}
|
|
781
|
+
.bar .branch[hidden]{display:none}
|
|
782
|
+
.bar .branch.dirty{color:var(--warn-text);border-color:var(--warn)}
|
|
783
|
+
|
|
784
|
+
/* new-session branch picker — matches the miniform vocabulary */
|
|
785
|
+
.brwrap{display:flex;flex-direction:column;gap:6px}
|
|
786
|
+
.brwrap[hidden]{display:none}
|
|
787
|
+
.brwrap select{min-height:38px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;
|
|
788
|
+
background:var(--chip);color:var(--text-2);font-family:var(--mono);font-size:11px}
|
|
789
|
+
.brwrap select:focus{border-color:var(--accent)}
|
|
790
|
+
.brwrap input[hidden]{display:none}
|
|
791
|
+
.miniform .hint.warn{color:var(--warn-text)}
|
|
792
|
+
|
|
793
|
+
/* ── scratchpad drawer: bottom of the terminal column, collapsible, never overlays anything ── */
|
|
794
|
+
.scratch{flex:none;border-top:1px solid var(--border-3);background:var(--panel);display:flex;flex-direction:column;min-height:0}
|
|
795
|
+
.scratch-head{display:flex;align-items:center;gap:8px;padding:5px 10px;min-height:34px;
|
|
796
|
+
background:var(--panel-head);border-bottom:1px solid transparent}
|
|
797
|
+
.scratch[data-open="true"] .scratch-head{border-bottom-color:var(--border-3)}
|
|
798
|
+
.scratch-toggle{display:inline-flex;align-items:center;gap:7px;border:0;background:none;
|
|
799
|
+
font-family:var(--mono);font-size:8.5px;letter-spacing:.15em;text-transform:uppercase;color:var(--text-dim);cursor:pointer}
|
|
800
|
+
.scratch-toggle:hover{color:var(--text-2)}
|
|
801
|
+
.scratch-toggle .chev{display:inline-block;transition:transform .15s;font-size:9px}
|
|
802
|
+
.scratch[data-open="true"] .scratch-toggle .chev{transform:rotate(180deg)}
|
|
803
|
+
.scratch-status{font-family:var(--mono);font-size:9.5px;color:var(--text-dim);letter-spacing:.04em}
|
|
804
|
+
.scratch-actions{margin-left:auto;display:flex;gap:6px}
|
|
805
|
+
.scratch-actions .mini{min-height:28px;padding:0 10px;border:1px solid var(--border);border-radius:7px;
|
|
806
|
+
background:var(--chip);color:var(--accent-text);font-family:var(--mono);font-size:10px;letter-spacing:0;cursor:pointer}
|
|
807
|
+
.scratch-actions .mini:hover{border-color:var(--accent)}
|
|
808
|
+
.scratch-actions .mini:disabled{color:var(--text-dim);border-color:var(--border-3);cursor:not-allowed;opacity:.6}
|
|
809
|
+
.scratch-text{display:none;width:100%;box-sizing:border-box;resize:vertical;height:22vh;min-height:90px;max-height:45vh;
|
|
810
|
+
border:0;padding:10px 12px;background:var(--term-bg);color:var(--text-2);
|
|
811
|
+
font-family:var(--mono);font-size:12px;line-height:1.5;outline:none}
|
|
812
|
+
.scratch[data-open="true"] .scratch-text{display:block}
|
package/workspace/server.js
CHANGED
|
@@ -36,12 +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
|
|
40
|
-
console.error(' rm -rf node_modules && npm install
|
|
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.`);
|
|
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)');
|
|
42
41
|
process.exit(1);
|
|
43
42
|
}
|
|
44
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
|
+
|
|
45
63
|
const { checkSurface } = require('./lib/surface-contract');
|
|
46
64
|
const scheduleLogic = require('./lib/schedule-logic');
|
|
47
65
|
const harness = require('./lib/harness-parse');
|
|
@@ -51,6 +69,7 @@ const remoteAccess = require('./lib/remote-access'); // tailnet bind + token g
|
|
|
51
69
|
const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
|
|
52
70
|
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
53
71
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
72
|
+
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
54
73
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
55
74
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
56
75
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -77,6 +96,7 @@ const PUB = path.join(__dirname, 'public');
|
|
|
77
96
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
78
97
|
const STATE = path.join(__dirname, 'state');
|
|
79
98
|
const SESS_DIR = path.join(STATE, 'sessions');
|
|
99
|
+
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
80
100
|
const WS_FILE = path.join(STATE, 'workspaces.json');
|
|
81
101
|
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
82
102
|
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
@@ -819,20 +839,18 @@ function appendBuf(s, data) {
|
|
|
819
839
|
s.parsed = null; // buffer moved; the cached parse is stale
|
|
820
840
|
}
|
|
821
841
|
|
|
822
|
-
// Platform-correct guidance when a spawn fails. `posix_spawnp failed`
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
// newer npm rejects it as an unknown config).
|
|
842
|
+
// Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
|
|
843
|
+
// node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
|
|
844
|
+
// the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
|
|
845
|
+
// re-applies + verifies the fix; a wrong-arch binary (x64 Node on Apple Silicon) is the rarer
|
|
846
|
+
// alternative, cured by a clean reinstall.
|
|
828
847
|
function spawnFailureAdvice(err, cmd, isShell) {
|
|
829
848
|
const msg = String(err && err.message || err);
|
|
830
849
|
if (/posix_spawnp|EFAULT|ENOEXEC|wrong architecture|not valid/i.test(msg)) {
|
|
831
|
-
return `could not spawn '${cmd}' (${msg}).
|
|
832
|
-
`
|
|
833
|
-
`
|
|
834
|
-
`
|
|
835
|
-
`fail — try Node 22 LTS.`;
|
|
850
|
+
return `could not spawn '${cmd}' (${msg}). On macOS this is almost always node-pty's prebuilt ` +
|
|
851
|
+
`spawn-helper lacking its executable bit. Fix: run \`pmos doctor\` (it restores +x and verifies), ` +
|
|
852
|
+
`then ↻ respawn. If it persists, the binary is wrong-arch — \`rm -rf node_modules && npm install\` ` +
|
|
853
|
+
`in the workspace install dir.`;
|
|
836
854
|
}
|
|
837
855
|
if (process.platform === 'win32') {
|
|
838
856
|
return `could not start '${cmd}': ${msg}. Native Windows needs WSL for a real PTY shell — ` +
|
|
@@ -905,8 +923,21 @@ function ensureTmuxSession(name, cmd, workdir, cols, rows) {
|
|
|
905
923
|
function attachPty(s) {
|
|
906
924
|
const name = tmuxName(s.id);
|
|
907
925
|
const startedMs = Date.now();
|
|
908
|
-
|
|
909
|
-
|
|
926
|
+
let p;
|
|
927
|
+
try {
|
|
928
|
+
p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
|
|
929
|
+
{ name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
|
|
930
|
+
} catch (e) {
|
|
931
|
+
// A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
|
|
932
|
+
// NOT propagate: on the startup first-shell it would crash the whole server and blank the
|
|
933
|
+
// entire UI ("no terminal painted"). Degrade to an honest exited pane carrying the fix, exactly
|
|
934
|
+
// like the direct-PTY path does — the server stays up and the session shows why + a respawn.
|
|
935
|
+
s.pty = null; s.state = 'exited';
|
|
936
|
+
s.note = spawnFailureAdvice(e, s.agent || 'session', false);
|
|
937
|
+
appendBuf(s, `\x1b[38;5;203m✗ ${s.note}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`);
|
|
938
|
+
persist(s); pushSessions();
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
910
941
|
s.pty = p; s.tmux = name; s.state = 'working';
|
|
911
942
|
// Start the quiet clock at ATTACH, not at first byte: a session that spawns and immediately
|
|
912
943
|
// hangs should go quiet on schedule rather than read 'working' forever. On a restore this
|
|
@@ -1029,6 +1060,38 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1029
1060
|
persist(s); pushSessions(); pushWorkspaces();
|
|
1030
1061
|
return s;
|
|
1031
1062
|
}
|
|
1063
|
+
|
|
1064
|
+
// New session, optionally on a chosen/created branch. The working tree is SHARED across a repo's
|
|
1065
|
+
// sessions, so the checkout is repo-wide — the UI says so. A FAILED checkout must never spawn a
|
|
1066
|
+
// session on the wrong branch (rubric hard floor): we return {ok:false} and spawn nothing. Kept as
|
|
1067
|
+
// its own function so the checkout-then-maybe-spawn contract is unit-testable without a WS client.
|
|
1068
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch }) {
|
|
1069
|
+
if (branch) {
|
|
1070
|
+
const w = getWs(ws);
|
|
1071
|
+
const r = gitInfo.checkout(w.root, branch, { create: !!createBranch });
|
|
1072
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
1073
|
+
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
1074
|
+
}
|
|
1075
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws });
|
|
1076
|
+
return { ok: true, session: s };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ── Scratchpad: per-workspace staging text (R4). Local-only, one file per workspace keyed by the
|
|
1080
|
+
// workspace's hashed id (never a caller-supplied path), capped so it can't be used as a blob store.
|
|
1081
|
+
const SCRATCH_MAX = 200_000;
|
|
1082
|
+
function scratchpadPath(w) { return w ? path.join(SCRATCH_DIR, w.id + '.txt') : null; }
|
|
1083
|
+
function readScratchpad(w) {
|
|
1084
|
+
const f = scratchpadPath(w);
|
|
1085
|
+
if (!f) return '';
|
|
1086
|
+
try { return fs.readFileSync(f, 'utf8'); } catch { return ''; }
|
|
1087
|
+
}
|
|
1088
|
+
function writeScratchpad(w, text) {
|
|
1089
|
+
const f = scratchpadPath(w);
|
|
1090
|
+
if (!f) throw new Error('no workspace');
|
|
1091
|
+
fs.mkdirSync(SCRATCH_DIR, { recursive: true });
|
|
1092
|
+
fs.writeFileSync(f, String(text == null ? '' : text).slice(0, SCRATCH_MAX));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1032
1095
|
// Remove a session from the registry + disk. Only non-live sessions can be removed;
|
|
1033
1096
|
// a live one must be killed first (its onExit flips it to exited).
|
|
1034
1097
|
function removeSession(id) {
|
|
@@ -1524,6 +1587,28 @@ async function handleRequest(req, res) {
|
|
|
1524
1587
|
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
|
|
1525
1588
|
}
|
|
1526
1589
|
if (p === '/api/sessions') return json(res, { sessions: sessionList() });
|
|
1590
|
+
if (p === '/api/git/branches') {
|
|
1591
|
+
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
1592
|
+
// branch). Fixed git args, no caller input in the read path (R1).
|
|
1593
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1594
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
1595
|
+
return json(res, gitInfo.branchInfo(w.root));
|
|
1596
|
+
}
|
|
1597
|
+
if (p === '/api/scratchpad' && req.method === 'POST') {
|
|
1598
|
+
let body = {};
|
|
1599
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1600
|
+
catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1601
|
+
const w = getWs(body.ws);
|
|
1602
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1603
|
+
try { writeScratchpad(w, body.text); }
|
|
1604
|
+
catch (e) { warnWriteFail('scratchpad', e); return json(res, { ok: false, error: 'could not save scratchpad' }, 500); }
|
|
1605
|
+
return json(res, { ok: true });
|
|
1606
|
+
}
|
|
1607
|
+
if (p === '/api/scratchpad') {
|
|
1608
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1609
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1610
|
+
return json(res, { ok: true, text: readScratchpad(w) });
|
|
1611
|
+
}
|
|
1527
1612
|
if (p === '/api/doc') {
|
|
1528
1613
|
const w = getWs(url.searchParams.get('ws'));
|
|
1529
1614
|
const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
|
|
@@ -1716,8 +1801,9 @@ wss.on('connection', (ws) => {
|
|
|
1716
1801
|
} else if (m.t === 'resize') {
|
|
1717
1802
|
const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
|
|
1718
1803
|
} else if (m.t === 'new') {
|
|
1719
|
-
const
|
|
1720
|
-
ws.send(JSON.stringify({ t: '
|
|
1804
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch });
|
|
1805
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: 'branch: ' + r.error })); return; }
|
|
1806
|
+
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
1721
1807
|
} else if (m.t === 'respawn') {
|
|
1722
1808
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
1723
1809
|
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws });
|