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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Reference
3
3
  title: "create-pmos"
4
- timestamp: 2026-07-23
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",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@f4589bf
1
+ kernel-source: pmos@4ce6980
2
2
  assembled: 2026-07-27
3
3
  mode: vendor
@@ -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 };
@@ -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
- await Promise.all([loadLibrary(), loadNeeds()]);
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
- send({ t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId });
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}
@@ -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 s = spawnSession({ name: m.name, agent: m.agent || 'shell', cwd: m.cwd, ws: m.ws });
1749
- ws.send(JSON.stringify({ t: 'created', id: s.id }));
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 });