create-pmos 0.38.0 → 0.38.2

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.
@@ -18,6 +18,7 @@
18
18
  * is unchanged. The workspace app itself is bundled in this package, not downloaded.
19
19
  */
20
20
  'use strict';
21
+ const os = require('os');
21
22
  const fs = require('fs');
22
23
  const path = require('path');
23
24
  const { spawnSync } = require('child_process');
@@ -193,13 +194,33 @@ function installedWorkspaceVersion() {
193
194
  const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
194
195
  const r = spawnSync(npmCmd, ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' });
195
196
  if (r.status !== 0 || !r.stdout) return null;
196
- try { return require(path.join(r.stdout.trim(), 'pmos-workspace', 'package.json')).version || null; }
197
- catch { return null; }
197
+ const dir = path.join(r.stdout.trim(), 'pmos-workspace');
198
+ try {
199
+ // A SYMLINK is not an install. `npm install -g <folder>` links to the folder and installs no
200
+ // dependencies, so the link reads as the bundled version (it IS the bundled folder, here the npx
201
+ // cache) while @xterm and node-pty are absent. Report it as unusable so it is replaced by a copy.
202
+ if (fs.lstatSync(dir).isSymbolicLink()) return null;
203
+ // A real copy must also carry its dependencies, or the terminal page cannot load.
204
+ if (!fs.existsSync(path.join(dir, 'node_modules', '@xterm', 'xterm', 'package.json'))) return null;
205
+ return require(path.join(dir, 'package.json')).version || null;
206
+ } catch { return null; }
198
207
  }
208
+ // Install a COPY, never a link: pack the bundled app and install the tarball. A tarball install
209
+ // unpacks into the global root and installs the app's own dependencies there (builds node-pty once).
199
210
  function installWorkspaceGlobal() {
200
211
  const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
201
- return spawnSync(npmCmd, ['install', '-g', WORKSPACE, '--no-audit', '--no-fund'],
202
- { stdio: 'inherit', shell: process.platform === 'win32' }).status;
212
+ const shell = process.platform === 'win32';
213
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-ws-'));
214
+ try {
215
+ const pack = spawnSync(npmCmd, ['pack', WORKSPACE, '--pack-destination', tmp, '--silent'],
216
+ { encoding: 'utf8', shell });
217
+ const tgz = pack.status === 0 ? (pack.stdout || '').trim().split('\n').pop() : '';
218
+ if (!tgz) { console.error(`create-pmos: could not pack the workspace app${pack.stderr ? ' — ' + pack.stderr.trim() : ''}`); return 1; }
219
+ return spawnSync(npmCmd, ['install', '-g', path.join(tmp, tgz), '--no-audit', '--no-fund'],
220
+ { stdio: 'inherit', shell }).status;
221
+ } finally {
222
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
223
+ }
203
224
  }
204
225
  // A persistent server started by the OLD code keeps serving OLD assets after an upgrade — stop it so
205
226
  // the next launch brings the server up on the freshly installed code. Best-effort; ignore failures.
@@ -251,8 +272,8 @@ function workspace(targetArg, opts) {
251
272
  // Global install refused (common on system-owned npm prefixes) — don't fail, still open the GUI.
252
273
  console.log('\n⚠ Could not put `pmos` on your PATH automatically (your npm global prefix may be locked down).');
253
274
  console.log(' To get the bare `pmos` command for next time, run ONE of:');
254
- console.log(` npm install -g "${WORKSPACE}" # then: pmos`);
255
- console.log(` sudo npm install -g "${WORKSPACE}" # if your node is system-owned`);
275
+ console.log(` npm pack "${WORKSPACE}" && npm install -g ./pmos-workspace-*.tgz # then: pmos`);
276
+ console.log(` sudo npm install -g ./pmos-workspace-*.tgz # if your node is system-owned`);
256
277
  console.log(` Docs: npm prefix without sudo → https://docs.npmjs.com/resolving-eacces-permissions-errors`);
257
278
  console.log(' Opening the GUI now from the bundled copy…\n');
258
279
  process.exit(launchWorkspace(targetDir, false));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
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. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `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@cb0de51
1
+ kernel-source: pmos@6270efe
2
2
  assembled: 2026-10-02
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-02T01:05:10Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-02T01:05:10Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-02T01:05:10Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -43,6 +43,7 @@ const PINNED = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : nul
43
43
  // this one's pid file — that is exactly how `pmos stop` was left unable to find the real server.
44
44
  const pidFile = (port) => path.join(STATE, D.pidFileName(port));
45
45
  const logFile = (port) => path.join(STATE, `server-${port}.log`);
46
+ const APP_VERSION = (() => { try { return require('../package.json').version || null; } catch { return null; } })();
46
47
  const urlOf = (port) => `http://127.0.0.1:${port}`;
47
48
  const NO_OPEN = process.env.PMOS_NO_OPEN === '1'; // tests set this
48
49
 
@@ -85,7 +86,7 @@ async function discover() {
85
86
  const ports = D.candidatePorts({ stateDir: STATE, pinned: PINNED });
86
87
  const hits = await Promise.all(ports.map(async (port) => {
87
88
  const s = await probe(port);
88
- return s ? { port, sessions: s.sessions } : null;
89
+ return s ? { port, sessions: s.sessions, version: s.version || null } : null;
89
90
  }));
90
91
  return hits.filter(Boolean);
91
92
  }
@@ -160,10 +161,19 @@ function fixSpawnHelperPerms() {
160
161
  try { const st = fs.statSync(f); if (!(st.mode & 0o111)) fs.chmodSync(f, st.mode | 0o755); } catch {}
161
162
  }
162
163
  }
164
+ // Every runtime dependency must RESOLVE from this app's own directory — not just node-pty. A copy
165
+ // with node-pty but no @xterm (or no node_modules at all) starts a server whose terminal page cannot
166
+ // load, and says nothing until the PM clicks a session.
167
+ function missingDeps() {
168
+ let deps = [];
169
+ try { deps = Object.keys(require('../package.json').dependencies || {}); } catch {}
170
+ return deps.filter((d) => !fs.existsSync(path.join(WS, 'node_modules', ...d.split('/'), 'package.json')));
171
+ }
163
172
  function ensureInstalled() {
164
- if (!fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) {
165
- say('First run — installing the workspace (one-time, ~20s)…');
166
- if (!npm(['install', '--no-audit', '--no-fund'])) {
173
+ const missing = missingDeps();
174
+ if (missing.length) {
175
+ say(`First run — installing the workspace (one-time, ~20s)… missing: ${missing.join(', ')}`);
176
+ if (!npm(['install', '--omit=dev', '--no-audit', '--no-fund']) || missingDeps().length) {
167
177
  say('✗ install failed. Run `pmos doctor` for the exact fix.');
168
178
  return false;
169
179
  }
@@ -213,6 +223,15 @@ async function choosePort() {
213
223
  async function up(dir) {
214
224
  // 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
215
225
  let live = await discover();
226
+ // A server that predates this install is running OLD code under NEW files (or the reverse). Reusing
227
+ // it is the failure behind "not found is not valid JSON": the page asks for routes that process
228
+ // never learned. Sessions are tmux-backed, so replacing the server does not end them.
229
+ const stale = live.filter((l) => l.version !== APP_VERSION);
230
+ if (stale.length) {
231
+ say(`note: the running workspace is ${stale[0].version ? 'version ' + stale[0].version : 'an older version'}; this install is ${APP_VERSION}. Restarting it on the new code — sessions keep running in tmux.`);
232
+ for (const s of stale) await stopOne(s.port);
233
+ live = await discover();
234
+ }
216
235
  if (live.length) {
217
236
  const port = live[0].port;
218
237
  if (live.length > 1) {
@@ -123,14 +123,30 @@ function worktreePath(root, branch) {
123
123
  return path.join(path.dirname(top), path.basename(top) + '-worktrees', branch.replace(/\//g, '-'));
124
124
  }
125
125
 
126
+ // The start point of a NEW branch: an existing branch name or a commit id, nothing else. A name that
127
+ // passes validBranchName cannot begin with `-`, so it cannot be read by git as an option; it must
128
+ // also resolve to a commit, so a typo is refused here with a reason and not by git's own message.
129
+ // No base means "where this tree is now" — git's default.
130
+ function baseRef(root, from) {
131
+ if (from === null || from === undefined || from === '') return { ok: true, ref: null };
132
+ const ref = String(from);
133
+ if (!validBranchName(ref) && !/^[0-9a-f]{7,40}$/i.test(ref)) return { ok: false, error: 'invalid base' };
134
+ const r = git(root, ['rev-parse', '--verify', '--quiet', ref + '^{commit}']);
135
+ if (r.failed) return { ok: false, error: 'git not available' };
136
+ if (r.code !== 0) return { ok: false, error: `base '${ref}' does not exist` };
137
+ return { ok: true, ref };
138
+ }
139
+
126
140
  // Check out an existing branch, or create one with -b. THIS MOVES THE WHOLE REPO — every session
127
141
  // in every workspace under it. Confined to `root` by cwd; the name is validated first so nothing
128
142
  // malformed reaches git. On any failure returns { ok:false, error } and leaves the caller to NOT
129
143
  // spawn a session on the wrong branch (a failed checkout must never spawn).
130
- function checkout(root, branch, { create = false } = {}) {
144
+ function checkout(root, branch, { create = false, from = null } = {}) {
131
145
  if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
132
146
  if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
133
- const args = create ? ['checkout', '-b', branch, '--'] : ['checkout', branch, '--'];
147
+ const base = baseRef(root, from);
148
+ if (!base.ok) return base;
149
+ const args = create ? ['checkout', '-b', branch, ...(base.ref ? [base.ref] : []), '--'] : ['checkout', branch, '--'];
134
150
  const r = git(root, args);
135
151
  if (r.failed) return { ok: false, error: 'git not available' };
136
152
  if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'checkout failed').split('\n')[0].slice(0, 200) };
@@ -142,10 +158,12 @@ function checkout(root, branch, { create = false } = {}) {
142
158
  // session stays untouched. `create` makes the branch (`-b`); otherwise the branch must exist and
143
159
  // must not already be checked out somewhere else (git refuses — we return that plainly).
144
160
  // Returns { ok, dir, branch, reused } or { ok:false, error }.
145
- function addWorktree(root, branch, { create = false } = {}) {
161
+ function addWorktree(root, branch, { create = false, from = null } = {}) {
146
162
  const top = repoRoot(root);
147
163
  if (!top) return { ok: false, error: 'not a git repository' };
148
164
  if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
165
+ const base = baseRef(top, from);
166
+ if (!base.ok) return base;
149
167
 
150
168
  // Already checked out somewhere? Say where, rather than letting git's message be the first the
151
169
  // PM sees — and if it is exactly the worktree we would have made, reuse it instead of failing.
@@ -161,7 +179,7 @@ function addWorktree(root, branch, { create = false } = {}) {
161
179
  try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
162
180
  catch (e) { return { ok: false, error: `cannot create ${path.dirname(dir)}: ${e.code || e.message}` }; }
163
181
 
164
- const args = create ? ['worktree', 'add', '-b', branch, dir] : ['worktree', 'add', dir, branch];
182
+ const args = create ? ['worktree', 'add', '-b', branch, dir, ...(base.ref ? [base.ref] : [])] : ['worktree', 'add', dir, branch];
165
183
  const r = git(top, args);
166
184
  if (r.failed) return { ok: false, error: 'git not available' };
167
185
  if (r.code !== 0) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
17
  "doctor": "node doctor.js",
18
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js",
18
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -997,7 +997,7 @@ export function productTrees(name) {
997
997
  const key = p.root;
998
998
  return p.trees.map((t) => {
999
999
  const m = /^initiative\/(.+)$/.exec(t.branch || '');
1000
- return { ws: t.id, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
1000
+ return { ws: t.id, dir: t.tree || t.root, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
1001
1001
  initiative: m ? m[1] : null, by: m ? 'branch' : null };
1002
1002
  }).sort((a, b) => (b.main - a.main));
1003
1003
  }
@@ -1202,6 +1202,36 @@ export async function preferProfile(agent, profile) {
1202
1202
  return postJson('/api/profiles/prefer', { agent, profile });
1203
1203
  }
1204
1204
 
1205
+ /** Re-read the registered trees only — no boards. A worktree created or removed anywhere (a spawn, the
1206
+ * branch form, a terminal) reaches panel 1 through this, instead of waiting for a reload. */
1207
+ export async function refreshTrees() {
1208
+ if (FIXTURE) return;
1209
+ const out = await getJson('/api/workspaces');
1210
+ if (!out || !Array.isArray(out.workspaces)) return;
1211
+ const rows = foldWorkspaces(out.workspaces);
1212
+ for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
1213
+ publishBoards();
1214
+ }
1215
+
1216
+ /** Create a branch with no session. `worktree` false = a checkout in the shared tree (the server
1217
+ * refuses it while sessions run there). */
1218
+ export async function createBranch({ ws, name, from, worktree }) {
1219
+ if (FIXTURE) return { ok: false, error: 'fixture mode creates no branch' };
1220
+ return postJson('/api/git/branch', { ws, name, from: from || null, worktree: worktree !== false });
1221
+ }
1222
+
1223
+ /** End a session and remove it, with an answer. */
1224
+ export async function removeSessionById(id) {
1225
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no sessions' };
1226
+ return postJson('/api/session/remove', { id });
1227
+ }
1228
+
1229
+ /** Remove a linked worktree folder. The server refuses a busy or unsaved one and says why. */
1230
+ export async function removeWorktree(ws, dir) {
1231
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no worktrees' };
1232
+ return postJson('/api/git/worktrees/remove', { ws, dir });
1233
+ }
1234
+
1205
1235
  /** Every working tree of this workspace's repo. The pre-flight reads it for ONE purpose: a branch
1206
1236
  * checked out elsewhere is a branch git will refuse, and the PM should read that in the picker
1207
1237
  * rather than as an error after the click. */
@@ -31,7 +31,7 @@ function read(key, url) {
31
31
  if (!hit || hit.state === 'ok') {
32
32
  const next = { state: hit ? hit.state : 'loading', data: hit ? hit.data : null, at: hit ? hit.at : 0 };
33
33
  cache.set(key, next);
34
- fetch(url).then((r) => r.json()).then(
34
+ fetch(url).then(readJson).then(
35
35
  (data) => { cache.set(key, { state: data && data.ok === false ? 'error' : 'ok', data, error: data && data.error, at: Date.now() }); setState({}); },
36
36
  (e) => { cache.set(key, { state: 'error', error: (e && e.message) || String(e), at: Date.now() }); setState({}); },
37
37
  );
@@ -40,8 +40,20 @@ function read(key, url) {
40
40
  return hit;
41
41
  }
42
42
  const reload = (key) => cache.delete(key);
43
+ /* A route this server does not have answers 404 with the plain text `not found`. Parsing that as JSON
44
+ * showed the PM "Unexpected token 'o', \"not found\" is not valid JSON" — true, and no help. The usual
45
+ * cause is a server still running code older than the page, so say that and the one command. */
46
+ function readJson(r) {
47
+ return r.text().then((t) => {
48
+ try { return JSON.parse(t); } catch {
49
+ throw new Error(r.status === 404
50
+ ? 'this server does not have that route — it is probably running an older version than the page. Run `pmos` again to restart it'
51
+ : 'the server answered ' + r.status + ' with something that is not JSON');
52
+ }
53
+ });
54
+ }
43
55
  const post = (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
44
- .then((r) => r.json()).catch((e) => ({ ok: false, error: (e && e.message) || String(e) }));
56
+ .then(readJson).catch((e) => ({ ok: false, error: (e && e.message) || String(e) }));
45
57
  function status(r, what) {
46
58
  if (r.state === 'loading' && !r.data) return el('div', { style: NOTE }, iv('reading ' + what + ' …'));
47
59
  if (r.state === 'error') return el('div', { style: NOTE + ';color:var(--destructive)' }, iv(what + ' could not be read — ' + (r.error || 'error')));
@@ -321,19 +333,73 @@ function candidatesBlock(wsId) {
321
333
  }
322
334
  return kids;
323
335
  }
336
+ /* Discovery is its own section at the TOP: it used to sit under the whole skill list, below the fold,
337
+ * so a PM who scrolled nothing saw no discovery at all. */
338
+ function discoveryBlock(wsId) {
339
+ const sch = read('schedules', '/api/schedules?all=1');
340
+ const mine = ((sch.data && sch.data.schedules) || []).find((x) => x.skill === 'skill-discover' || x.task === 'skill-discover');
341
+ const kids = [el('div', { style: LABEL + ';color:var(--foreground)', 'data-r13': 'discovery-label' }, iv('skill discovery')),
342
+ el('div', { style: NOTE }, iv('Finds skills in your own repository and from sources you name. Nothing it finds runs until you read it and approve it.')),
343
+ el('div', { style: 'display:flex;gap:4px;flex-wrap:wrap;padding:0 14px 6px' },
344
+ mine ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'discover-now', onClick: async () => { await post('/api/schedules/run', { id: mine.id }); reload('schedules'); setState({}); } }, 'find skills now') : null,
345
+ mine ? el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);align-self:center' }, iv('weekly · next ' + (mine.next ? String(mine.next).slice(0, 16).replace('T', ' ') : '—')))
346
+ : seedRow('skills', 'set up weekly skill discovery', 'schedules the skill-discover skill weekly (Wednesday 09:00) for the selected repository; it files candidates for you to read and never approves them'))];
347
+ return [...kids, ...candidatesBlock(wsId)];
348
+ }
324
349
  export function skillsLive() {
325
350
  const wsId = dataMod && state.product ? dataMod.wsOf(state.product) : null;
326
351
  const r = read('skills:' + (wsId || ''), '/api/skills' + (wsId ? '?ws=' + encodeURIComponent(wsId) : ''));
327
352
  const st = status(r, 'the skills');
328
- if (st) return [st];
353
+ const head = discoveryBlock(wsId);
354
+ if (st) return [el('div', { style: 'padding:6px 0' }, [...head, st])];
329
355
  const list = (r.data && r.data.skills) || [];
330
- const kids = list.map((sk) => el('div', { style: ROW, 'data-r4': 'skill' },
356
+ const open = state.detail && state.detail.kind === 'skill' ? state.detail.id : null;
357
+ const kids = list.map((sk) => el('button', { style: ROW + (open === sk.id ? ';background:var(--accent)' : ''), hover: 'background:var(--accent)', 'data-r4': 'skill', 'data-id': sk.id,
358
+ onClick: () => setState({ detail: { kind: 'skill', id: sk.id } }) },
331
359
  el('span', { style: 'display:flex;gap:8px;align-items:baseline' },
332
360
  el('span', { style: 'font-family:var(--mono);font-size:12px' }, iv(sk.label || sk.skill || sk.id)),
333
361
  el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(sk.source))),
334
362
  el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground)' }, iv(sk.description))));
335
363
  if (!list.length) kids.push(el('div', { style: NOTE }, iv('No skills — a successful read of nothing.')));
336
- return [el('div', { style: 'padding:6px 0' }, [...kids, ...candidatesBlock(wsId)])];
364
+ return [el('div', { style: 'padding:6px 0' }, [...head, el('div', { style: LABEL + ';color:var(--foreground)' }, iv('skills · ' + list.length + ' · click one to read it')), ...kids])];
365
+ }
366
+
367
+ /* The detail of one skill (panel 3). The text is read from the server on open. Your own skills are
368
+ * editable and save in place; a builtin or an approved discovered skill is read-only, and "make my own
369
+ * copy" writes an editable one beside it, so the original is never changed under a review. */
370
+ let skillDraft = { id: null, text: null }; let skillNote = null;
371
+ export function skillDetailLive(det) {
372
+ const id = det && det.id;
373
+ const wsId = dataMod && state.product ? dataMod.wsOf(state.product) : null;
374
+ const r = read('skill:' + id, '/api/skills/read?id=' + encodeURIComponent(id || '') + (wsId ? '&ws=' + encodeURIComponent(wsId) : ''));
375
+ const st = status(r, 'this skill');
376
+ if (st) return [st];
377
+ const d = r.data;
378
+ if (skillDraft.id !== id) skillDraft = { id, text: null };
379
+ const text = skillDraft.text !== null ? skillDraft.text : d.text;
380
+ const dirty = skillDraft.text !== null && skillDraft.text !== d.text;
381
+ const out = [el('div', { style: 'padding:14px 14px 6px' },
382
+ el('div', { style: 'font-family:var(--mono);font-size:13px' }, iv(d.label)),
383
+ el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);margin-top:2px' }, iv(d.source + (d.editable ? ' · yours — edit it below' : ' · read-only') + ' · ' + d.file)),
384
+ el('div', { style: 'font-size:12px;margin-top:6px' }, iv(d.description)),
385
+ (d.output || []).length ? el('div', { style: 'font-size:11px;color:var(--muted-foreground);margin-top:6px' }, iv('always produces: ' + d.output.join(' · '))) : null)];
386
+ out.push(el('div', { style: 'padding:6px 14px' }, el('textarea', { 'data-r13': 'skill-text', spellcheck: 'false', readonly: d.editable ? null : 'readonly',
387
+ style: 'width:100%;min-height:340px;font-family:var(--mono);font-size:11.5px;line-height:1.5;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);padding:8px;resize:vertical',
388
+ onInput: (e) => { skillDraft = { id, text: e.target.value }; } }, text)));
389
+ out.push(el('div', { style: 'display:flex;gap:6px;align-items:center;padding:6px 14px 14px;flex-wrap:wrap' },
390
+ d.editable ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'skill-save', onClick: async () => {
391
+ const res = await post('/api/skills/save', { id, text: skillDraft.text !== null ? skillDraft.text : d.text });
392
+ skillNote = res && res.ok ? 'saved' : ((res && res.error) || 'not saved');
393
+ if (res && res.ok) { skillDraft = { id, text: null }; reload('skill:' + id); reload('skills:' + (wsId || '')); }
394
+ setState({});
395
+ } }, 'save') : el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'skill-copy', onClick: async () => {
396
+ const res = await post('/api/skills/copy', { id, ws: wsId });
397
+ if (res && res.ok) { skillNote = 'copied as ' + res.id + ' — it is yours to edit'; reload('skills:' + (wsId || '')); setState({ detail: { kind: 'skill', id: res.id } }); }
398
+ else { skillNote = (res && res.error) || 'not copied'; setState({}); }
399
+ } }, 'make my own copy to edit'),
400
+ dirty ? el('span', { style: 'font-size:11px;color:var(--muted-foreground)' }, 'unsaved changes') : null,
401
+ skillNote ? el('span', { 'data-r13': 'skill-note', style: 'font-size:11px;color:var(--muted-foreground)' }, iv(skillNote)) : null));
402
+ return out;
337
403
  }
338
404
 
339
405
  /* ── knowledge ──────────────────────────────────────────────────────────────────────────────── */
@@ -1052,7 +1052,7 @@ const LISTS = {
1052
1052
  const BODIES = {
1053
1053
  flow: flowBody,
1054
1054
  sched: schedBody,
1055
- skill: skillBody,
1055
+ skill: (det) => (FIXTURE ? skillBody(det) : LIVE.skillDetailLive(det)),
1056
1056
  know: knowBody,
1057
1057
  agent: agentBody,
1058
1058
  mem: memBody,
@@ -33,6 +33,7 @@ import { el } from './dom.js';
33
33
  import { state, setState, FIXTURE } from './state.js';
34
34
  import * as FX from './fixture.js';
35
35
  import { openTerm as openTermTab } from './panel-initiatives.js';
36
+ import { startHere } from './panel-trees.js';
36
37
 
37
38
  /* Both are another module's file, loaded lazily for the reason panel-tabs.js gives: a static import
38
39
  * of a file that fails to load takes this whole module down with it, and a panel that cannot draw
@@ -214,7 +215,7 @@ function branchSection() {
214
215
  return out;
215
216
  }
216
217
 
217
- const current = info.branch || null;
218
+ const current = info.current || info.branch || null;
218
219
  const branches = Array.isArray(info.branches) ? info.branches : [];
219
220
  /* A branch checked out in ANOTHER working tree is a branch git will refuse to check out here.
220
221
  * Offering it and letting the server say no would be technically honest and practically rude:
@@ -237,11 +238,22 @@ function branchSection() {
237
238
  for (const b of branches) {
238
239
  if (b === current) continue;
239
240
  const taken = elsewhere.get(b);
241
+ /* A branch already open in another working tree cannot be checked out twice, but it CAN be worked
242
+ * in: the chip starts a session in that tree. It used to be a disabled chip, so the folder the PM
243
+ * already had was the one place they could not start. */
244
+ const treeOf = taken && dataMod && typeof dataMod.productTrees === 'function'
245
+ ? dataMod.productTrees(state.product).find((t) => t.branch === b && !t.main) : null;
240
246
  chips.push(taken
241
- ? el('button', {
242
- disabled: true, title: 'git will not check this out twice — it is already in ' + taken,
243
- style: CHIP_DEAD,
244
- }, iv(b + ' — in ' + shortPath(taken)))
247
+ ? (treeOf
248
+ ? el('button', {
249
+ onClick: () => { closeNewSession(); startHere(state.product, treeOf); },
250
+ title: 'this branch is already open in ' + taken + ' — start the session there',
251
+ style: CHIP, hover: HOVER, 'data-r14': 'open-worktree',
252
+ }, iv(b + ' — open that worktree'))
253
+ : el('button', {
254
+ disabled: true, title: 'git will not check this out twice — it is already in ' + taken,
255
+ style: CHIP_DEAD,
256
+ }, iv(b + ' — in ' + shortPath(taken))))
245
257
  : el('button', {
246
258
  onClick: () => pick(b),
247
259
  style: state.newSessBranch === b && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
@@ -435,6 +447,7 @@ async function onStart() {
435
447
  name,
436
448
  agent: state.newSessAgent || 'shell',
437
449
  ws: state.wsId || null,
450
+ initiative: slug || undefined,
438
451
  branch: branch || null,
439
452
  createBranch,
440
453
  // Sent explicitly, ALWAYS, and only when a branch is in play — the server defaults an omitted
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { el, setChildren } from './dom.js';
27
27
  import { syncButton } from './lifecycle.js';
28
+ import { treesBlock, branchForm, openBranchForm, treeNoteFor } from './panel-trees.js';
28
29
  import { state, setState, FIXTURE } from './state.js';
29
30
  import {
30
31
  productsList, repoRow, firstSlugOf, MODE_META, SETTINGS_GROUPS, ADDABLE_PRODUCTS,
@@ -282,17 +283,18 @@ function repoEntry(tuple, product) {
282
283
  if (FIXTURE || !dataMod) return row;
283
284
  const name = tuple[0];
284
285
  const trees = treesOf(name);
285
- const linked = trees.filter((t) => !t.main);
286
- const open = !!(state.treesOpen || {})[name];
286
+ /* The trees list is always available — the main tree is a line of work too, and a repository with
287
+ * one tree still has branches to create. The active repository opens it by default; others on a
288
+ * click of the chip. */
289
+ const stored = (state.treesOpen || {})[name];
290
+ const open = stored === undefined ? product === name : !!stored;
287
291
  const tools = [];
288
- if (linked.length) {
289
- tools.push(el('button', {
290
- onClick: () => setState((s) => ({ treesOpen: { ...(s.treesOpen || {}), [name]: !open } })),
291
- style: chipStyle, hover: 'color:var(--foreground)',
292
- title: linked.length + ' linked worktree' + (linked.length === 1 ? '' : 's') + ' — show the lines of work',
293
- 'data-r1': 'trees-chip',
294
- }, ip('⑂ ' + linked.length)));
295
- }
292
+ tools.push(el('button', {
293
+ onClick: () => setState((s) => ({ treesOpen: { ...(s.treesOpen || {}), [name]: !open } })),
294
+ style: chipStyle, hover: 'color:var(--foreground)',
295
+ title: trees.length + ' working tree' + (trees.length === 1 ? '' : 's') + ' — show the lines of work, their sessions and branches',
296
+ 'data-r1': 'trees-chip', 'data-repo': name,
297
+ }, ip('⑂ ' + trees.length)));
296
298
  if (product === name) {
297
299
  { const sb = syncButton(name); if (sb) tools.push(sb); }
298
300
  tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
@@ -301,18 +303,9 @@ function repoEntry(tuple, product) {
301
303
  const kids = [el('div', { style: 'display:flex;align-items:center' },
302
304
  el('div', { style: 'flex:1;min-width:0' }, row),
303
305
  tools.length ? el('div', { style: 'display:flex;align-items:center;gap:2px;padding-right:8px' }, tools) : null)];
304
- if (open && trees.length) {
305
- kids.push(el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees' },
306
- trees.map((t) => el('div', {
307
- style: 'display:flex;gap:8px;font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);padding:2px 0',
308
- title: t.initiative ? 'initiative read from the branch name (initiative/<id>) — not a recorded attribution' : '',
309
- },
310
- el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
311
- el('span', null, ip(t.sessions + ' session' + (t.sessions === 1 ? '' : 's'))),
312
- t.initiative ? el('span', null, ip('#' + t.initiative)) : null,
313
- )),
314
- ));
315
- }
306
+ { const bf = branchForm(name); if (bf) kids.push(bf); }
307
+ if (open && trees.length) kids.push(treesBlock(name, trees, product === name));
308
+ { const tn = treeNoteFor(name); if (tn) kids.push(tn); }
316
309
  const n = state.repoNotice;
317
310
  if (n && n.name === name) {
318
311
  kids.push(el('div', {
@@ -0,0 +1,176 @@
1
+ /* panel-trees.js — the working trees of a repository, as things you can use (workspace-branch-sessions).
2
+ *
3
+ * Panel 1 used to list a repository's trees as plain text behind a chip that existed only when a
4
+ * linked worktree did. A branch could not be clicked, its sessions could not be seen, a session
5
+ * could not be started in it, and a branch could not be made without starting a session. This module
6
+ * is that surface:
7
+ *
8
+ * treesBlock(name) every tree, main first. A row opens to show that tree's sessions and its
9
+ * actions: start a session HERE, remove the folder (linked trees only).
10
+ * branchForm(name) `+ branch` — a name, a base and an own-folder toggle. Creates the branch and
11
+ * starts nothing; then offers a session on it.
12
+ *
13
+ * Every action states its outcome in the panel. A refusal says why and the repository is unchanged.
14
+ * Live mode only: the fixture has no server, and panel-repos.js never calls this there.
15
+ */
16
+ import { el } from './dom.js';
17
+ import { state, setState, subscribe } from './state.js';
18
+
19
+ const ip = (v) => el('span', null, v);
20
+ const MONO = 'font-family:var(--mono);font-size:10.5px';
21
+ const BTN = MONO + ';padding:2px 8px;border:1px solid var(--border);color:var(--muted-foreground);min-height:24px';
22
+ const MUTED = 'color:var(--muted-foreground)';
23
+
24
+ let dataMod = null; let termMod = null; let iniMod = null;
25
+ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
26
+ import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
27
+ import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
28
+
29
+ const note = (name, text, tone) => setState({ treeNote: { name, text, tone: tone || null } });
30
+ const refresh = () => (dataMod && dataMod.refreshTrees ? dataMod.refreshTrees() : Promise.resolve());
31
+
32
+ /* A tree was registered, removed or renamed somewhere (the server's `workspaces` push, a branch
33
+ * change in a terminal): re-read the tree list. Guarded by the push stamp so the repaint this causes
34
+ * does not ask again. */
35
+ let lastPush = 0;
36
+ subscribe(() => {
37
+ if (state.treesPush && state.treesPush !== lastPush) { lastPush = state.treesPush; refresh(); }
38
+ });
39
+
40
+ /** The server's sessions that run in this tree, from the list the page already polls. */
41
+ export function sessionsIn(ws) {
42
+ return (Array.isArray(state.sessions) ? state.sessions : []).filter((s) => s && s.ws === ws);
43
+ }
44
+ const isEnded = (s) => s.state === 'exited' || s.shown === 'exited';
45
+
46
+ /** Open a session's terminal. The row carries the server's id, so the tab attaches to that exact PTY. */
47
+ function openSession(s) {
48
+ if (!iniMod) return;
49
+ iniMod.openTerm(null, { id: s.id, name: s.name, st: isEnded(s) ? 'exited' : 'working', profile: s.profile_id || null });
50
+ }
51
+
52
+ /** Start a session in THIS tree: the workspace id is the tree's, so the server runs it in that folder. */
53
+ export async function startHere(name, t) {
54
+ if (!termMod || !iniMod) { note(name, 'the terminal module has not loaded — nothing was started', 'bad'); return; }
55
+ const label = (t.branch || 'tree').replace(/^initiative\//, '');
56
+ const res = await termMod.requestSpawn({ name: 'shell · ' + label, agent: 'shell', ws: t.ws, branch: null, createBranch: false, isolate: null, profile: null });
57
+ if (!res || !res.ok) { note(name, (res && res.error) || 'the workspace server refused, without saying why', 'bad'); return; }
58
+ note(name, 'started in ' + (t.branch || 'this tree'));
59
+ openSession({ id: res.id, name: 'shell · ' + label, profile_id: null });
60
+ }
61
+
62
+ async function endSession(name, s) {
63
+ const out = dataMod ? await dataMod.removeSessionById(s.id) : null;
64
+ setState({ treeConfirm: null });
65
+ if (!out || out.ok === false) { note(name, (out && out.error) || 'the session could not be removed', 'bad'); return; }
66
+ note(name, out.removed === false ? (out.note || 'asked it to end') : 'session removed');
67
+ }
68
+
69
+ async function removeTree(name, t) {
70
+ const ok = typeof window === 'undefined' || !window.confirm ? true
71
+ : window.confirm('Remove the folder for “' + t.branch + '”?\n\n' + t.dir + '\n\nThe branch is kept. A folder with unsaved work or a running session is refused.');
72
+ if (!ok) return;
73
+ const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
74
+ if (!out || out.ok === false) { note(name, (out && out.error) || 'the folder could not be removed', 'bad'); return; }
75
+ setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: null } }));
76
+ note(name, 'removed the folder for ' + t.branch + ' — the branch is kept');
77
+ refresh();
78
+ }
79
+
80
+ function sessionRow(name, s) {
81
+ const armed = state.treeConfirm === s.id;
82
+ return el('div', { style: 'display:flex;align-items:center;gap:6px;padding:1px 0', 'data-r14': 'tree-session', 'data-id': s.id },
83
+ el('button', { onClick: () => openSession(s), style: MONO + ';flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--foreground)', hover: 'text-decoration:underline', title: 'open this session' },
84
+ ip(s.name + ' · ' + (isEnded(s) ? 'ended' : (s.shown || s.state || 'running')))),
85
+ el('button', {
86
+ onClick: () => (armed ? endSession(name, s) : setState({ treeConfirm: s.id })),
87
+ style: BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''), hover: 'color:var(--destructive)',
88
+ title: isEnded(s) ? 'remove this ended session' : 'end this session and remove it', 'data-r14': 'tree-end',
89
+ }, armed ? 'sure?' : (isEnded(s) ? 'remove' : 'end')));
90
+ }
91
+
92
+ function treeRow(name, t) {
93
+ const sel = (state.treeSel || {})[name] === t.ws;
94
+ const sess = sessionsIn(t.ws);
95
+ const live = sess.filter((s) => !isEnded(s)).length;
96
+ const head = el('button', {
97
+ onClick: () => setState((s) => ({ treeSel: { ...(s.treeSel || {}), [name]: sel ? null : t.ws }, treeConfirm: null })),
98
+ style: MONO + ';display:flex;gap:8px;width:100%;text-align:left;padding:3px 0;min-height:26px;align-items:baseline', hover: 'background:var(--accent)',
99
+ title: t.initiative ? 'initiative read from the branch name (initiative/<id>) — not a recorded attribution' : 'show this tree’s sessions',
100
+ 'data-r14': 'tree', 'data-ws': t.ws,
101
+ },
102
+ el('span', { style: MUTED }, ip(sel ? '▾' : '▸')),
103
+ el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
104
+ el('span', { style: MUTED }, ip(live + ' live' + (sess.length > live ? ' · ' + (sess.length - live) + ' ended' : ''))),
105
+ t.initiative ? el('span', { style: MUTED }, ip('#' + t.initiative)) : null);
106
+ if (!sel) return head;
107
+ return el('div', null, head, el('div', { style: 'padding:2px 0 6px 14px', 'data-r14': 'tree-open' },
108
+ sess.length ? sess.map((s) => sessionRow(name, s)) : el('div', { style: MONO + ';' + MUTED + ';padding:2px 0' }, 'no sessions in this tree'),
109
+ el('div', { style: 'display:flex;gap:6px;padding-top:4px;flex-wrap:wrap' },
110
+ el('button', { onClick: () => startHere(name, t), style: BTN, hover: 'color:var(--foreground)', title: 'start a shell session in ' + (t.dir || 'this tree'), 'data-r14': 'tree-start' }, '+ session here'),
111
+ t.main ? null : el('button', { onClick: () => removeTree(name, t), style: BTN, hover: 'color:var(--destructive)', title: 'remove this worktree folder (the branch is kept)', 'data-r14': 'tree-remove' }, 'remove folder'))));
112
+ }
113
+
114
+ /** The trees of one repository, main first. `name` is the repository's name in the data layer. */
115
+ export function treesBlock(name, trees, addable) {
116
+ return el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees', 'data-repo': name }, trees.map((t) => treeRow(name, t)),
117
+ addable ? el('button', { onClick: () => openBranchForm(name, trees), style: BTN + ';margin-top:4px', hover: 'color:var(--foreground)',
118
+ title: 'create a branch — with or without its own folder — without starting a session', 'data-r14': 'branch-add' }, '+ branch') : null);
119
+ }
120
+
121
+ /* ── + branch ───────────────────────────────────────────────────────────────────────────────── */
122
+ const INPUT = MONO + ';width:100%;padding:4px 6px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);min-height:26px';
123
+
124
+ export function openBranchForm(name, trees) {
125
+ const main = (trees || []).find((t) => t.main) || (trees || [])[0];
126
+ setState({ branchForm: { repo: name, name: '', from: '', folder: true, busy: false, error: null, ws: main ? main.ws : null, current: main ? main.branch : null } });
127
+ }
128
+
129
+ async function submitBranch() {
130
+ const f = state.branchForm;
131
+ if (!f || f.busy || !dataMod) return;
132
+ const name = String(f.name || '').trim();
133
+ if (!name) { setState({ branchForm: { ...f, error: 'name the new branch' } }); return; }
134
+ setState({ branchForm: { ...f, busy: true, error: null } });
135
+ const out = await dataMod.createBranch({ ws: f.ws, name, from: String(f.from || '').trim() || null, worktree: f.folder });
136
+ if (!out || out.ok === false) { setState({ branchForm: { ...f, busy: false, error: (out && out.error) || 'the server refused, without saying why' } }); return; }
137
+ setState((s) => ({ branchForm: null, treesOpen: { ...(s.treesOpen || {}), [f.repo]: true }, treeSel: { ...(s.treeSel || {}), [f.repo]: out.ws }, treeNote: { name: f.repo, text: '“' + name + '” created' + (f.folder ? ' in its own folder — ' + out.dir : ' in the shared folder'), tone: null } }));
138
+ refresh();
139
+ }
140
+
141
+ /* The panel repaints on every state change, which replaces the input the PM is typing in. The value
142
+ * survives (it lives on the form record); the focus and caret are restored here so typing is not cut. */
143
+ function keepFocus(f, which) {
144
+ return (n) => {
145
+ if ((f.focus || 'name') !== which) return;
146
+ setTimeout(() => { try { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } catch { /* detached */ } }, 0);
147
+ };
148
+ }
149
+
150
+ /** The `+ branch` form for the repository it was opened on, or null. */
151
+ export function branchForm(name) {
152
+ const f = state.branchForm;
153
+ if (!f || f.repo !== name) return null;
154
+ const upd = (patch) => setState((s) => ({ branchForm: { ...s.branchForm, ...patch } }));
155
+ return el('div', { style: 'padding:6px 14px 10px 39px;display:grid;gap:5px', 'data-r14': 'branch-form' },
156
+ el('input', { value: f.name, placeholder: 'initiative/my-branch', style: INPUT, 'data-r14': 'branch-name',
157
+ ref: keepFocus(f, 'name'), onFocus: () => { f.focus = 'name'; },
158
+ onInput: (e) => { f.name = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') submitBranch(); if (e.key === 'Escape') setState({ branchForm: null }); } }),
159
+ el('input', { value: f.from, placeholder: 'start from: ' + (f.current || 'the current branch') + ' (or another branch)', style: INPUT, 'data-r14': 'branch-from',
160
+ ref: keepFocus(f, 'from'), onFocus: () => { f.focus = 'from'; },
161
+ onInput: (e) => { f.from = e.target.value; }, onKeydown: (e) => { if (e.key === 'Enter') submitBranch(); } }),
162
+ el('button', { onClick: () => upd({ folder: !f.folder }), style: MONO + ';text-align:left;' + MUTED, hover: 'color:var(--foreground)', 'data-r14': 'branch-folder',
163
+ title: f.folder ? 'the branch gets its own folder; sessions already running are untouched' : 'the branch is checked out in the shared folder; refused while a session runs there' },
164
+ ip((f.folder ? '☑' : '☐') + ' its own folder (worktree)')),
165
+ f.error ? el('div', { style: 'font-size:11px;color:var(--destructive)', 'data-r14': 'branch-error' }, ip(f.error)) : null,
166
+ el('div', { style: 'display:flex;gap:6px' },
167
+ el('button', { onClick: submitBranch, disabled: f.busy ? 'disabled' : null, style: BTN, hover: 'color:var(--foreground)', 'data-r14': 'branch-create' }, f.busy ? 'creating…' : 'create branch'),
168
+ el('button', { onClick: () => setState({ branchForm: null }), style: BTN, hover: 'color:var(--foreground)' }, 'cancel')));
169
+ }
170
+
171
+ /** The outcome line of the last tree action for this repository. */
172
+ export function treeNoteFor(name) {
173
+ const n = state.treeNote;
174
+ if (!n || n.name !== name) return null;
175
+ return el('div', { style: 'padding:4px 14px 8px 39px;font-size:11px;overflow-wrap:anywhere;color:' + (n.tone === 'bad' ? 'var(--destructive)' : 'var(--muted-foreground)'), 'data-r14': 'tree-note' }, ip(n.text));
176
+ }
@@ -244,6 +244,9 @@ export function requestSpawn(opts) {
244
244
  name: o.name,
245
245
  agent: o.agent || 'shell',
246
246
  ws: o.ws,
247
+ // The initiative the session is FOR, when the caller knows it (workspace-branch-sessions R8):
248
+ // the `⋯ options` path used to drop it, so a session started with a branch carried none.
249
+ ...(o.initiative ? { initiative: o.initiative } : {}),
247
250
  /* Sent even when the PM chose nothing, and that is deliberate on two of them. `isolate` has
248
251
  * a server-side default (`server.js` liveSessionCount) whose own comment says the client
249
252
  * should state what the PM actually saw; `profile: null` is a DELIBERATE "no profile",
@@ -429,9 +432,12 @@ function handle(m) {
429
432
  * cache of the server's last word about a workspace, and a reader that is not open ignores it.
430
433
  * The `ws` is carried so a reader can tell whether the news is about the repository it is
431
434
  * asking about. */
432
- setState({ branchInfo: { ...m, at: Date.now() } });
435
+ setState({ branchInfo: { ...m, at: Date.now() }, treesPush: Date.now() });
433
436
  return;
434
437
  }
438
+ /* A tree was registered, removed or renamed. Panel 1 re-reads the tree list on `treesPush`
439
+ * (panel-repos.js); without this the new folder appeared only after a reload. */
440
+ if (m.t === 'workspaces') { setState({ treesPush: Date.now() }); return; }
435
441
  }
436
442
 
437
443
  const recFor = (sessionId) => {
@@ -116,6 +116,7 @@ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists
116
116
  const PUB = path.join(__dirname, 'public');
117
117
  const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
118
118
  const XROOT = path.join(__dirname, 'node_modules', '@xterm');
119
+ const APP_VERSION = (() => { try { return require('./package.json').version || null; } catch { return null; } })();
119
120
  // ── WHERE THE PM'S RUNTIME STATE LIVES (see lib/state-root.js for the whole argument). ─────────
120
121
  // It is NOT `workspace/state/` any more. That directory is inside the application directory the
121
122
  // test suite runs from, which put the PM's live sessions, workspace registry and authored skills
@@ -1983,14 +1984,17 @@ function filingContract(w) {
1983
1984
  // D76 — `root` adds the repository's APPROVED discovered skills, loaded FIRST so the PM's own skill of the same id
1984
1985
  // overrides one and a built-in overrides both. Only an approved candidate whose file still hashes to what was approved
1985
1986
  // is ever offered; its harness is forced to an agent skill running claude.
1986
- function loadTasks(root) {
1987
+ // The loaded skill records, each with the `file` it came from. loadTasks maps these to the task shape
1988
+ // and drops the file; the skill detail routes below need the file, so both read through here.
1989
+ function loadSkillRecords(root) {
1987
1990
  const discovered = root ? skillsReg.loadApproved(root, SKILL_APPROVALS, skillStore.parseSkill).skills : [];
1988
1991
  return skillStore.loadSkills([
1989
1992
  ...(discovered.length ? [{ skills: discovered }] : []),
1990
1993
  { dir: userSkillsDir(), source: 'user' },
1991
1994
  { dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
1992
- ]).map(toTask);
1995
+ ]);
1993
1996
  }
1997
+ function loadTasks(root) { return loadSkillRecords(root).map(toTask); }
1994
1998
  // Map a loaded skill to the internal task shape fireSchedule/the picker use. `kind` is derived from the
1995
1999
  // file's `run` and stays INTERNAL — it is never a field the PM selects.
1996
2000
  function toTask(s) {
@@ -3040,6 +3044,40 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
3040
3044
  return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
3041
3045
  }
3042
3046
 
3047
+ // Create a branch WITHOUT starting a session (workspace-branch-sessions R5). Two shapes, as in the
3048
+ // spawn path: its own folder (a worktree, registered as a workspace so the page can start sessions in
3049
+ // it) or a checkout in the shared tree. The shared checkout moves every session in that tree, so it is
3050
+ // refused while any are running — the spawn path may do that on a PM's explicit choice, but a control
3051
+ // that only creates a branch must not move other people's work as a side effect.
3052
+ function createBranchOnly({ ws, name, from, worktree }) {
3053
+ const w = ws ? workspaces.get(ws) : null;
3054
+ if (!w) return { ok: false, error: 'no such workspace' };
3055
+ if (!gitInfo.isRepo(w.root)) return { ok: false, error: `“${w.name}” is not a git repository` };
3056
+ const branch = String(name == null ? '' : name).trim();
3057
+ if (!branch) return { ok: false, error: 'name the new branch' };
3058
+ if (worktree) {
3059
+ const r = gitInfo.addWorktree(w.root, branch, { create: true, from });
3060
+ if (!r.ok) return { ok: false, error: r.error };
3061
+ const info = gitInfo.branchInfo(w.root);
3062
+ const mirrored = info.ok && info.subdir ? path.join(r.dir, info.subdir) : r.dir;
3063
+ const dir = isDir(mirrored) ? mirrored : r.dir;
3064
+ const nw = registerWorkspace(dir, `${w.name} ⑂ ${branch}`);
3065
+ if (!nw) return { ok: false, error: `worktree created at ${r.dir} but could not be registered as a workspace` };
3066
+ broadcast({ t: 'branch', ws: nw.id, ...gitInfo.branchInfo(nw.root) });
3067
+ pushWorkspaces();
3068
+ return { ok: true, ws: nw.id, dir: r.dir, branch };
3069
+ }
3070
+ const live = treeTenants(treeForWs(w.id), null).length;
3071
+ if (live > 0) {
3072
+ return { ok: false, error: `${live} session${live === 1 ? ' is' : 's are'} running in this folder, and a checkout would move ${live === 1 ? 'it' : 'them'} — create the branch in its own folder instead` };
3073
+ }
3074
+ const r = gitInfo.checkout(w.root, branch, { create: true, from });
3075
+ if (!r.ok) return { ok: false, error: r.error };
3076
+ broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
3077
+ pushWorkspaces();
3078
+ return { ok: true, ws: w.id, dir: w.root, branch };
3079
+ }
3080
+
3043
3081
  /* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
3044
3082
  * The composer asked six questions to open a terminal, and the one question the PM actually has —
3045
3083
  * "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
@@ -4286,6 +4324,27 @@ async function handleRequest(req, res) {
4286
4324
  w.label = name; saveWorkspaces(); pushWorkspaces();
4287
4325
  return json(res, { ok: true, workspace: { id: w.id, name, label: name } });
4288
4326
  }
4327
+ if (p === '/api/git/branch' && req.method === 'POST') {
4328
+ let body = {};
4329
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4330
+ const out = createBranchOnly({ ws: body.ws, name: body.name, from: body.from || null, worktree: body.worktree !== false });
4331
+ return json(res, out, out.ok ? 200 : 400);
4332
+ }
4333
+ // End a session and remove it, with an answer. The WebSocket kill/remove messages return nothing,
4334
+ // so a refusal or a slow exit left the PM clicking a control that seemed to do nothing.
4335
+ if (p === '/api/session/remove' && req.method === 'POST') {
4336
+ let body = {};
4337
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4338
+ const sess = sessions.get(String(body.id || ''));
4339
+ if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
4340
+ if (sess.pty) {
4341
+ if (sess.tmux) tmuxKillSession(sess.tmux);
4342
+ try { sess.pty.kill(); } catch {}
4343
+ for (let i = 0; i < 30 && sess.pty; i++) await new Promise((r) => setTimeout(r, 100));
4344
+ if (sess.pty) return json(res, { ok: true, removed: false, note: 'asked it to end; it has not exited yet — press again to remove it' });
4345
+ }
4346
+ return json(res, { ok: removeSession(sess.id), removed: true });
4347
+ }
4289
4348
  if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
4290
4349
  // Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
4291
4350
  // list of THIS repo's linked worktrees before anything is removed — the main tree is
@@ -4590,6 +4649,44 @@ async function handleRequest(req, res) {
4590
4649
  }
4591
4650
  if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
4592
4651
  // ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
4652
+ // One skill's full text, for the detail panel. Only the PM's own skills (source `user`) are
4653
+ // editable: a builtin ships with the app and a discovered one is pinned to the text the PM approved,
4654
+ // so editing either in place would change what was reviewed. Both can be copied to an editable one.
4655
+ if (p === '/api/skills/read' && req.method === 'GET') {
4656
+ const sw = getWs(url.searchParams.get('ws'));
4657
+ const rec = loadSkillRecords(sw ? sw.root : null).find((x) => x.id === url.searchParams.get('id'));
4658
+ if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
4659
+ let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
4660
+ return json(res, { ok: true, id: rec.id, label: rec.label || rec.id, source: rec.source, editable: rec.source === 'user',
4661
+ file: path.basename(rec.file), description: rec.description || '', output: rec.output || [], text });
4662
+ }
4663
+ if (p === '/api/skills/save' && req.method === 'POST') {
4664
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4665
+ const rec = loadSkillRecords(null).find((x) => x.id === body.id && x.source === 'user');
4666
+ if (!rec) return json(res, { ok: false, error: 'only your own skills can be edited here' }, 403);
4667
+ const text = typeof body.text === 'string' ? body.text : '';
4668
+ if (!text.trim() || text.length > 200000) return json(res, { ok: false, error: 'the skill text is empty or too long' }, 400);
4669
+ const parsed = skillStore.parseSkill(text);
4670
+ if (parsed.name !== rec.id) return json(res, { ok: false, error: `the name: line must stay "${rec.id}" — rename by copying instead` }, 400);
4671
+ if (!String(parsed.description || '').trim()) return json(res, { ok: false, error: 'the skill needs a description: line' }, 400);
4672
+ try { const tmp = rec.file + '.tmp'; fs.writeFileSync(tmp, text.endsWith('\n') ? text : text + '\n'); fs.renameSync(tmp, rec.file); }
4673
+ catch { return json(res, { ok: false, error: 'the skill file could not be written' }, 500); }
4674
+ return json(res, { ok: true, id: rec.id });
4675
+ }
4676
+ if (p === '/api/skills/copy' && req.method === 'POST') {
4677
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4678
+ const sw = getWs(body.ws);
4679
+ const all = loadSkillRecords(sw ? sw.root : null);
4680
+ const rec = all.find((x) => x.id === body.id);
4681
+ if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
4682
+ let id = rec.id + '-copy';
4683
+ for (let n = 2; all.some((x) => x.id === id); n++) id = rec.id + '-copy-' + n;
4684
+ let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
4685
+ text = text.replace(/^name:.*$/m, 'name: ' + id).replace(/^label:\s*"?(.*?)"?\s*$/m, (_, l) => 'label: "' + l.replace(/"/g, "'") + ' (my copy)"');
4686
+ try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, id + '.skill'), text, { flag: 'wx' }); }
4687
+ catch { return json(res, { ok: false, error: 'the copy could not be written' }, 500); }
4688
+ return json(res, { ok: true, id });
4689
+ }
4593
4690
  // GET lists every skill with NO kind field — from the user's view there is just a skill.
4594
4691
  if (p === '/api/skills' && req.method === 'GET') {
4595
4692
  const sw = getWs(url.searchParams.get('ws'));
@@ -4810,7 +4907,9 @@ async function handleRequest(req, res) {
4810
4907
  if (mc.ok) mc.core.logDecision(s.root, { kind: 'switch', session: s.id, agent: s.agent, tier: r.tier, model: r.model, by: 'human:wawan' });
4811
4908
  return json(res, { ok: true, model: r.model, tier: r.tier, note: 'typed /model into the pane; effort is unchanged' });
4812
4909
  }
4813
- if (p === '/api/sessions') return json(res, { sessions: sessionList() });
4910
+ // `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
4911
+ // advance under a live process, so the UI would otherwise call routes that process never learned.
4912
+ if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION });
4814
4913
  if (p === '/api/git/branches') {
4815
4914
  // Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
4816
4915
  // branch). Fixed git args, no caller input in the read path (R1).