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.
- package/bin/create-pmos.js +27 -6
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +23 -4
- package/workspace/lib/git-info.js +22 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +31 -1
- package/workspace/public/pm/modes-live.js +71 -5
- package/workspace/public/pm/modes.js +1 -1
- package/workspace/public/pm/panel-newsess.js +18 -5
- package/workspace/public/pm/panel-repos.js +15 -22
- package/workspace/public/pm/panel-trees.js +176 -0
- package/workspace/public/pm/tab-term.js +7 -1
- package/workspace/server.js +102 -3
package/bin/create-pmos.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
255
|
-
console.log(` sudo npm install -g
|
|
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-02T03:08:00Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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) {
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.38.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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' }, [...
|
|
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
|
-
?
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 (
|
|
305
|
-
|
|
306
|
-
|
|
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) => {
|
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
])
|
|
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
|
-
|
|
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).
|