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 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.2",
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@2e880f8
1
+ kernel-source: pmos@4ce6980
2
2
  assembled: 2026-07-27
3
3
  mode: vendor
@@ -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
- // Self-heal a partial/incomplete native build (node-pty ships no prebuilds, so `npm rebuild`
100
- // compiles from source) BEFORE the server tries to spawn a session and dies with posix_spawnp.
101
- if (!nodePtyBuildOk()) {
102
- say('node-pty is present but its native build is incomplete — rebuilding from source…');
103
- npm(['rebuild', 'node-pty']);
104
- }
105
- if (!nodePtyBuildOk()) {
106
- const toolchain = process.platform === 'darwin' ? 'xcode-select --install'
107
- : process.platform === 'win32' ? 'Visual Studio Build Tools (C++)'
108
- : 'apt install build-essential python3';
109
- say('✗ node-pty could not be built on this machine. Run `pmos doctor` for the full diagnosis.');
110
- say(` Most often: install a C/C++ toolchain (${toolchain}),`);
111
- say(` or switch to Node 22 LTS (you are on ${process.version}; a very new/non-LTS Node may not build node-pty).`);
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
 
@@ -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
- // node-pty ships NO prebuilds — it compiles a native addon on install — so `npm rebuild
35
- // node-pty` (or a clean reinstall) already builds from source; the old `--build-from-source`
36
- // npm flag is unnecessary and newer npm rejects it as unknown config. These are the canonical
37
- // fixes, dir-qualified so they are copy-pasteable from anywhere.
38
- const TOOLCHAIN = process.platform === 'darwin' ? 'xcode-select --install'
39
- : process.platform === 'win32' ? 'Visual Studio Build Tools (C++)'
40
- : 'apt install build-essential python3';
41
- const REINSTALL = `cd "${WSDIR}" && rm -rf node_modules && npm install (recompiles node-pty from source; needs a C/C++ toolchain: ${TOOLCHAIN})`;
42
- const REINSTALL_LTS = `${REINSTALL}\n If it still fails on Node ${process.version}, that Node may be too new/non-LTS for node-pty to build — switch to Node 22 LTS and retry.`;
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 < 18) bad(`Node ${process.version} is too old`, 'install Node 18+ (arm64-native on Apple Silicon)');
47
- else if (major % 2 === 1) { checks.push(true); console.log(`✓ Node ${process.version} (>= 18)`); console.log(` • note: Node ${major} is an ODD (non-LTS) line — node-pty may lack a matching build. If the checks below fail, try Node 22 LTS.`); }
48
- else ok(`Node ${process.version} (>= 18)`);
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. the compiled binaries are present + arch-matched (the posix_spawnp culprit). node-pty
58
- // compiles pty.node (loaded by require) and, on macOS ONLY, a separate spawn-helper binary
59
- // (exec'd at spawn time — Linux uses forkpty directly and never builds it). A PARTIAL macOS
60
- // build — pty.node present but spawn-helper missing — loads fine (check 2 passes) yet every
61
- // session dies with 'posix_spawnp failed'. That is exactly the field failure on a too-new
62
- // Node, so on macOS check both, not just one.
63
- const rel = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release');
64
- const ptyNode = path.join(rel, 'pty.node');
65
- const helper = path.join(rel, 'spawn-helper');
66
- if (!fs.existsSync(ptyNode)) {
67
- bad('node-pty binary pty.node not found (compile did not complete)', REINSTALL_LTS);
68
- } else if (process.platform === 'darwin' && !fs.existsSync(helper)) {
69
- bad("node-pty's spawn-helper did not compile — pty.node loads but every session dies with 'posix_spawnp failed'", REINSTALL_LTS);
70
- } else {
71
- const info = binaryArch(ptyNode);
72
- const want = process.arch; // arm64 | x64 | …
73
- const got = info && info.arch;
74
- const match = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
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 (match) ok(`native binaries present${got && got !== 'universal' && info.x === 'Mach-O' ? ` (${got})` : ''}`);
77
- else bad(`ARCH MISMATCH: Node is ${want} but node-pty's binary is ${got} — this causes 'posix_spawnp failed' on every session`,
78
- `install a ${want}-native Node (e.g. \`brew install node\` on an arm64 shell), then: ${REINSTALL}`);
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
- ? `broken-native-build signature (wrong-arch or spawn-helper missing). ${REINSTALL_LTS}`
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 };
@@ -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}
@@ -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 (diagnoses arch/toolchain/Node version)');
40
- console.error(' rm -rf node_modules && npm install (recompiles node-pty from source)');
41
- console.error(` node-pty compiles a native addon — needs a C/C++ toolchain (macOS: xcode-select --install; Debian/Ubuntu: apt install build-essential python3) and a supported Node LTS. You are on ${process.version}; if it is a very new or non-LTS release the build may fail — try Node 22 LTS.`);
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` means node-pty's native
823
- // build is broken: either a wrong-arch binary (e.g. x64 Node on Apple Silicon) or an incomplete
824
- // compile (the spawn-helper never built — common on a brand-new/non-LTS Node). A shell change
825
- // won't fix it; a clean recompile will. `npm rebuild node-pty` compiles from source on its own —
826
- // node-pty ships no prebuilds — so the old `--build-from-source` npm flag is unnecessary (and
827
- // newer npm rejects it as an unknown config).
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}). node-pty's native build is broken (wrong-arch, or ` +
832
- `the spawn-helper never compiled). Fix: stop the server, run \`npm run doctor\` (it names the ` +
833
- `install dir + the exact cause), then in that dir \`rm -rf node_modules && npm install\`. ` +
834
- `Needs a C/C++ toolchain; on a very new or non-LTS Node (${process.version}) the compile can ` +
835
- `fail — try Node 22 LTS.`;
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
- const p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
909
- { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
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 s = spawnSession({ name: m.name, agent: m.agent || 'shell', cwd: m.cwd, ws: m.ws });
1720
- 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 }));
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 });