create-pmos 0.38.0 → 0.38.1

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.1",
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@d1d47bf
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-02T01:57:56Z"}
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-02T01:57:56Z"}
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-02T01:57:56Z"}
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) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
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.1",
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",
@@ -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,
@@ -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) {
@@ -4590,6 +4594,44 @@ async function handleRequest(req, res) {
4590
4594
  }
4591
4595
  if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
4592
4596
  // ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
4597
+ // One skill's full text, for the detail panel. Only the PM's own skills (source `user`) are
4598
+ // editable: a builtin ships with the app and a discovered one is pinned to the text the PM approved,
4599
+ // so editing either in place would change what was reviewed. Both can be copied to an editable one.
4600
+ if (p === '/api/skills/read' && req.method === 'GET') {
4601
+ const sw = getWs(url.searchParams.get('ws'));
4602
+ const rec = loadSkillRecords(sw ? sw.root : null).find((x) => x.id === url.searchParams.get('id'));
4603
+ if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
4604
+ let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
4605
+ return json(res, { ok: true, id: rec.id, label: rec.label || rec.id, source: rec.source, editable: rec.source === 'user',
4606
+ file: path.basename(rec.file), description: rec.description || '', output: rec.output || [], text });
4607
+ }
4608
+ if (p === '/api/skills/save' && req.method === 'POST') {
4609
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4610
+ const rec = loadSkillRecords(null).find((x) => x.id === body.id && x.source === 'user');
4611
+ if (!rec) return json(res, { ok: false, error: 'only your own skills can be edited here' }, 403);
4612
+ const text = typeof body.text === 'string' ? body.text : '';
4613
+ if (!text.trim() || text.length > 200000) return json(res, { ok: false, error: 'the skill text is empty or too long' }, 400);
4614
+ const parsed = skillStore.parseSkill(text);
4615
+ if (parsed.name !== rec.id) return json(res, { ok: false, error: `the name: line must stay "${rec.id}" — rename by copying instead` }, 400);
4616
+ if (!String(parsed.description || '').trim()) return json(res, { ok: false, error: 'the skill needs a description: line' }, 400);
4617
+ try { const tmp = rec.file + '.tmp'; fs.writeFileSync(tmp, text.endsWith('\n') ? text : text + '\n'); fs.renameSync(tmp, rec.file); }
4618
+ catch { return json(res, { ok: false, error: 'the skill file could not be written' }, 500); }
4619
+ return json(res, { ok: true, id: rec.id });
4620
+ }
4621
+ if (p === '/api/skills/copy' && req.method === 'POST') {
4622
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4623
+ const sw = getWs(body.ws);
4624
+ const all = loadSkillRecords(sw ? sw.root : null);
4625
+ const rec = all.find((x) => x.id === body.id);
4626
+ if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
4627
+ let id = rec.id + '-copy';
4628
+ for (let n = 2; all.some((x) => x.id === id); n++) id = rec.id + '-copy-' + n;
4629
+ let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
4630
+ text = text.replace(/^name:.*$/m, 'name: ' + id).replace(/^label:\s*"?(.*?)"?\s*$/m, (_, l) => 'label: "' + l.replace(/"/g, "'") + ' (my copy)"');
4631
+ try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, id + '.skill'), text, { flag: 'wx' }); }
4632
+ catch { return json(res, { ok: false, error: 'the copy could not be written' }, 500); }
4633
+ return json(res, { ok: true, id });
4634
+ }
4593
4635
  // GET lists every skill with NO kind field — from the user's view there is just a skill.
4594
4636
  if (p === '/api/skills' && req.method === 'GET') {
4595
4637
  const sw = getWs(url.searchParams.get('ws'));
@@ -4810,7 +4852,9 @@ async function handleRequest(req, res) {
4810
4852
  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
4853
  return json(res, { ok: true, model: r.model, tier: r.tier, note: 'typed /model into the pane; effort is unchanged' });
4812
4854
  }
4813
- if (p === '/api/sessions') return json(res, { sessions: sessionList() });
4855
+ // `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
4856
+ // advance under a live process, so the UI would otherwise call routes that process never learned.
4857
+ if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION });
4814
4858
  if (p === '/api/git/branches') {
4815
4859
  // Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
4816
4860
  // branch). Fixed git args, no caller input in the read path (R1).