create-pmos 0.6.3 → 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/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 +59 -2
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
|
@@ -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
|
@@ -69,6 +69,7 @@ const remoteAccess = require('./lib/remote-access'); // tailnet bind + token g
|
|
|
69
69
|
const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
|
|
70
70
|
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
71
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
|
|
72
73
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
73
74
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
74
75
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -95,6 +96,7 @@ const PUB = path.join(__dirname, 'public');
|
|
|
95
96
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
96
97
|
const STATE = path.join(__dirname, 'state');
|
|
97
98
|
const SESS_DIR = path.join(STATE, 'sessions');
|
|
99
|
+
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
98
100
|
const WS_FILE = path.join(STATE, 'workspaces.json');
|
|
99
101
|
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
100
102
|
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
@@ -1058,6 +1060,38 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1058
1060
|
persist(s); pushSessions(); pushWorkspaces();
|
|
1059
1061
|
return s;
|
|
1060
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
|
+
|
|
1061
1095
|
// Remove a session from the registry + disk. Only non-live sessions can be removed;
|
|
1062
1096
|
// a live one must be killed first (its onExit flips it to exited).
|
|
1063
1097
|
function removeSession(id) {
|
|
@@ -1553,6 +1587,28 @@ async function handleRequest(req, res) {
|
|
|
1553
1587
|
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
|
|
1554
1588
|
}
|
|
1555
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
|
+
}
|
|
1556
1612
|
if (p === '/api/doc') {
|
|
1557
1613
|
const w = getWs(url.searchParams.get('ws'));
|
|
1558
1614
|
const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
|
|
@@ -1745,8 +1801,9 @@ wss.on('connection', (ws) => {
|
|
|
1745
1801
|
} else if (m.t === 'resize') {
|
|
1746
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 {} }
|
|
1747
1803
|
} else if (m.t === 'new') {
|
|
1748
|
-
const
|
|
1749
|
-
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 }));
|
|
1750
1807
|
} else if (m.t === 'respawn') {
|
|
1751
1808
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
1752
1809
|
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws });
|