create-pmos 0.174.0 → 0.175.0
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/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/c3-sessions-panel-test.js +36 -0
- package/workspace/guide/console3-sessions.md +15 -3
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/typesafe-settings.js +20 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-dock.js +11 -3
- package/workspace/public/pm/c3-new-session.js +24 -8
- package/workspace/public/pm/c3-session-term.js +44 -4
- package/workspace/public/pm/console3.css +7 -2
- package/workspace/public/pm/sessions-panel.js +17 -13
- package/workspace/server.js +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.175.0",
|
|
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-13T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T01:08:07Z"}
|
|
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-13T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T01:08:07Z"}
|
|
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-13T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T01:08:07Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -63,6 +63,8 @@ function startShell(name) {
|
|
|
63
63
|
|
|
64
64
|
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_CLAUDE_HOME: CLAUDE_HOME }) });
|
|
65
65
|
await T.waitForServer(BASE);
|
|
66
|
+
/* an ended session the server does not hold: only its record on disk (written after the server read the folder) */
|
|
67
|
+
fs.writeFileSync(path.join(sessDir, 'sess-9901.json'), JSON.stringify({ id: 'sess-9901', name: 'old ended claude', agent: 'shell', state: 'exited', created, exitedAt: new Date(Date.now() - 864e5).toISOString(), origin: 'manual', cwd: path.resolve(__dirname, '..'), root: path.resolve(__dirname, '..') }));
|
|
66
68
|
|
|
67
69
|
/* ── A: /api/runs believes the server, not a stale file ── */
|
|
68
70
|
const r0 = await get('/api/runs');
|
|
@@ -153,6 +155,19 @@ function startShell(name) {
|
|
|
153
155
|
await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
|
|
154
156
|
await waitFor(page, () => !!document.querySelector('[data-c3-pad="text"]'));
|
|
155
157
|
ok('G9 the pad loads its text when the terminal opens again', await waitFor(page, () => document.querySelector('[data-c3-pad="text"]').value === 'flushed-on-close'));
|
|
158
|
+
/* the grip above the pad resizes it: a drag up makes the pad taller and the terminal shorter; the height is kept */
|
|
159
|
+
const geo = () => page.evaluate(() => ({ pad: document.querySelector('[data-c3-pad="text"]').getBoundingClientRect().height, term: document.querySelector('[data-c3-session-term]').getBoundingClientRect().height }));
|
|
160
|
+
const g0h = await geo();
|
|
161
|
+
const gb = await (await page.$('[data-c3-pad="grip"]')).boundingBox();
|
|
162
|
+
await page.mouse.move(gb.x + gb.width / 2, gb.y + gb.height / 2); await page.mouse.down();
|
|
163
|
+
await page.mouse.move(gb.x + gb.width / 2, gb.y + gb.height / 2 - 120, { steps: 6 }); await page.mouse.up();
|
|
164
|
+
const g1h = await geo();
|
|
165
|
+
ok('G11 dragging the grip up makes the pad taller and the terminal shorter', g1h.pad > g0h.pad + 80 && g1h.term < g0h.term - 80, JSON.stringify([g0h, g1h]));
|
|
166
|
+
ok('G11b the height is kept in localStorage', await page.evaluate(() => Number(localStorage.getItem('pmos.workspace.c3.term-pad-h')) > 4));
|
|
167
|
+
await page.focus('[data-c3-pad="grip"]'); await page.keyboard.press('ArrowDown');
|
|
168
|
+
ok('G12 the down arrow on the grip makes the pad shorter', (await geo()).pad < g1h.pad - 8);
|
|
169
|
+
await page.dblclick('[data-c3-pad="grip"]');
|
|
170
|
+
ok('G13 double-click gives back the default size', Math.abs((await geo()).pad - g0h.pad) < 3 && await page.evaluate(() => !localStorage.getItem('pmos.workspace.c3.term-pad-h')));
|
|
156
171
|
await page.click('[data-c3-pad="toggle"]');
|
|
157
172
|
ok('G10 the toggle hides the pad and the terminal gets the room', await page.evaluate(() => document.querySelector('[data-c3-pad="text"]').hidden === true && localStorage.getItem('pmos.workspace.c3.term-pad') === 'closed'));
|
|
158
173
|
await page.click('[data-c3-pad="toggle"]');
|
|
@@ -219,11 +234,16 @@ function startShell(name) {
|
|
|
219
234
|
ok('F1 Started by me has a New button', await waitFor(page, () => !!document.querySelector('[data-c3t="new"]')));
|
|
220
235
|
await page.click('[data-c3t="new"]');
|
|
221
236
|
ok('F2 New opens the new-session sheet on this page', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
|
|
237
|
+
const boards = (await get('/api/workspaces')).workspaces || [];
|
|
238
|
+
const opts = await page.evaluate(() => [...document.querySelectorAll('[data-c3ns="board"] option')].map((o) => ({ v: o.value, on: o.selected })));
|
|
239
|
+
ok('F2b the sheet has a Board select with the workspace\'s repositories', opts.length === boards.length && boards.every((w) => opts.some((o) => o.v === w.id)), JSON.stringify(opts));
|
|
240
|
+
ok('F2c the dock\'s repository is chosen', opts.filter((o) => o.on).length === 1, JSON.stringify(opts));
|
|
222
241
|
await page.fill('[data-c3ns="name"]', 'c3 new shell');
|
|
223
242
|
await page.selectOption('[data-c3ns="agent"]', 'shell');
|
|
224
243
|
await page.click('[data-c3ns="start"]');
|
|
225
244
|
const made = await until(async () => ((await get('/api/sessions')).sessions || []).find((s) => !before.has(s.id) && s.name === 'c3 new shell'), 10000);
|
|
226
245
|
ok('F3 Start starts the session on the server', !!made);
|
|
246
|
+
ok('F3b it starts on the board chosen in the sheet', !!made && made.ws === opts.find((o) => o.on).v, JSON.stringify(made && made.ws));
|
|
227
247
|
ok('F4 its terminal opens in a sheet on console3', !!made && await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), made.id) && page.url() === url0, page.url());
|
|
228
248
|
await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
229
249
|
await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
|
|
@@ -237,6 +257,22 @@ function startShell(name) {
|
|
|
237
257
|
await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
238
258
|
}
|
|
239
259
|
|
|
260
|
+
/* ── P: a past session can be removed, also one the server knows only from its record on disk ── */
|
|
261
|
+
if (await page.isVisible('[data-shell="sheet"] [aria-label="Close"]').catch(() => false)) await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
262
|
+
if (!(await page.isVisible('[data-c3t="past"]').catch(() => false))) await page.click('[data-c3pg="agent-button"]');
|
|
263
|
+
const pastSel = '[data-c3t="past"] [data-run="sess-9901"]';
|
|
264
|
+
ok('P1 the ended session is listed in Past', await waitFor(page, (s) => !!document.querySelector(s), pastSel));
|
|
265
|
+
if (!(await page.isVisible('[data-end-session="sess-9901"]').catch(() => false))) await page.click(pastSel + ' .nb');
|
|
266
|
+
ok('P2 its open row has Remove', await waitFor(page, () => /Remove/.test((document.querySelector('[data-end-session="sess-9901"]') || {}).textContent || '')));
|
|
267
|
+
ok('P3 it has no line about routes', !/GET \/api/.test(await page.evaluate((s) => document.querySelector(s).textContent, pastSel)));
|
|
268
|
+
await page.click('[data-end-session="sess-9901"]');
|
|
269
|
+
ok('P4 Remove asks first', await waitFor(page, () => !!document.querySelector('[data-end-confirm="sess-9901"]')));
|
|
270
|
+
await page.click('[data-end-yes="sess-9901"]');
|
|
271
|
+
ok('P5 the record is deleted', !!(await until(async () => !fs.existsSync(path.join(sessDir, 'sess-9901.json')), 6000)));
|
|
272
|
+
ok('P6 the row leaves Past', await waitFor(page, (s) => !document.querySelector(s), pastSel));
|
|
273
|
+
const bad = await fetch(BASE + '/api/session/remove', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: '../x' }) }).then((r) => r.status).catch(() => 0);
|
|
274
|
+
ok('P7 an id that is not a session id is refused', bad === 404 || bad === 403, String(bad));
|
|
275
|
+
|
|
240
276
|
ok('E1 the page threw no error', errors.length === 0, errors.join(' | '));
|
|
241
277
|
return finish();
|
|
242
278
|
})().catch((e) => { console.error(e); finish(1); });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Sessions in console3: start, open, message, read and end your sessions"
|
|
4
4
|
description: "How the Sessions panel in console3 groups your sessions, how to start a session, how to open a session's terminal without leaving console3, how to send a message to a session, and how to end a session."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-13
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Sessions in console3: start, open, message, read and end your sessions
|
|
@@ -23,11 +23,12 @@ You start a session in console3. You do not need the classic app.
|
|
|
23
23
|
- Press **New** next to **Started by me** in the Sessions panel.
|
|
24
24
|
2. The **New session** sheet opens. Type a name, or leave it empty to use `Shell` or `Claude`.
|
|
25
25
|
3. Select the **Agent**: Shell or Claude. A Claude session uses the profile, the launch flags and the tier from Settings.
|
|
26
|
-
4. Select
|
|
26
|
+
4. Select the **Board**: the repository that the session works in. The repository that is selected in the dock is the default.
|
|
27
|
+
5. Select where the session runs in **Runs on**. The list shows the working trees of the board that you selected:
|
|
27
28
|
- **Automatic**: the server selects the tree.
|
|
28
29
|
- A listed tree: the session runs in that working tree.
|
|
29
30
|
- **New branch**: type the branch name. The session runs in its own working copy of the new branch.
|
|
30
|
-
|
|
31
|
+
6. Press **Start**. The session starts, and its terminal opens in a sheet on the same page. If you selected a different board, the dock changes to that board.
|
|
31
32
|
|
|
32
33
|
If the server cannot start the session, the sheet shows the reason.
|
|
33
34
|
|
|
@@ -50,6 +51,7 @@ Below the terminal is a scratchpad. It keeps text for this session only. The tex
|
|
|
50
51
|
|
|
51
52
|
- To send the text to the session, press **Send to terminal**. The text is typed into the terminal. Enter is not pressed, so you can read it and press Enter yourself.
|
|
52
53
|
- To hide the pad, press **Scratchpad** above it. The text is kept. The terminal gets the room back. The page remembers if the pad was hidden.
|
|
54
|
+
- To make the pad taller or shorter, drag the grip on the line above it. Or select the grip and press the up arrow or the down arrow. The terminal gets the remaining room. The page remembers the height. To go back to the default height, double-click the grip.
|
|
53
55
|
- Esc in the pad closes the panel. Esc in the terminal goes to the terminal.
|
|
54
56
|
|
|
55
57
|
### The size of the terminal
|
|
@@ -94,3 +96,13 @@ If the connection to the workspace server is down, the message is not sent. The
|
|
|
94
96
|
When you end a session, its process stops and the session is removed. Any pending change stays a proposal. If the process does not stop immediately, press **End** again to remove it.
|
|
95
97
|
|
|
96
98
|
A session that is on a card has the same **End the session** control below its card.
|
|
99
|
+
|
|
100
|
+
## Remove a past session
|
|
101
|
+
|
|
102
|
+
A session under **Past (7 days)** has ended. To remove it from the list:
|
|
103
|
+
|
|
104
|
+
1. Press the row to open it.
|
|
105
|
+
2. Press **Remove**.
|
|
106
|
+
3. Press **Remove** to confirm, or press **Keep it** to cancel.
|
|
107
|
+
|
|
108
|
+
The record of the session is deleted. Its commits and its branch stay.
|
|
@@ -112,7 +112,7 @@ The workspace reads the TypeSafe key only on this machine. After you save the ke
|
|
|
112
112
|
If you have no TypeSafe key, a model on your computer can answer the same questions. Two models are supported. Both use the same interface as Jev.
|
|
113
113
|
|
|
114
114
|
- **Winnow**: in a `winnow-inference` folder, run `python3 scripts/serve.py`. It uses port 8091. Winnow-12B needs about 16 GB of memory.
|
|
115
|
-
- **Laya**: in Settings, press **Set up Laya**. A terminal named Set up Laya opens over the Settings page, with the commands typed in. If you close it, open it again from the session list. Read them and press Enter. The first time, they
|
|
115
|
+
- **Laya**: in Settings, press **Set up Laya**. A terminal named Set up Laya opens over the Settings page, with the commands typed in. If you close it, open it again from the session list. Read them and press Enter. The first time, they download a private copy of Python 3.12 (with uv) and Laya into the PMOS data folder. You do not install Python yourself, and nothing outside that folder changes. This first install downloads about a gigabyte and takes a few minutes. Then Laya starts and uses port 8000. Laya is small and runs on a laptop. Laya stops when you close that terminal or restart the computer. Press **Set up Laya** again to start it.
|
|
116
116
|
|
|
117
117
|
1. Start the model.
|
|
118
118
|
2. Open Settings, then **Privacy and data**. Under **Local model if Jev is missing**, press **Winnow** or **Laya**.
|
|
@@ -90,17 +90,34 @@ function status(stateDir, env = null) {
|
|
|
90
90
|
return { present: false, source: null, local: null };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Laya 0.4 and later need Python 3.10 and ship laya-serve; older releases install on Python 3.9 but cannot start.
|
|
94
|
+
const LAYA_PKG = 'laya[serve]>=0.4';
|
|
95
|
+
const LAYA_PY = '3.12';
|
|
96
|
+
const UV_INSTALLER = 'https://github.com/astral-sh/uv/releases/latest/download/uv-installer';
|
|
97
|
+
|
|
93
98
|
/** The commands that install Laya into its own folder under the runtime directory (once) and start it on loopback.
|
|
94
99
|
* The workspace types them into a terminal and never runs them itself: the PM reads them and presses Enter (as D91's
|
|
95
|
-
* Whisper setup does).
|
|
100
|
+
* Whisper setup does).
|
|
101
|
+
* They bring their own Python, so the PM installs nothing by hand (PM wawan, 2026-10-13). macOS ships Python 3.9, where
|
|
102
|
+
* pip quietly picks Laya 0.3.4, which has no laya-serve. So the commands fetch uv into `laya-tools/` (an unmanaged
|
|
103
|
+
* install: no PATH change and no receipt outside that folder), let uv download a private Python 3.12 into the same
|
|
104
|
+
* folder, rebuild `laya/` with it (a folder an earlier run made with Python 3.9 goes) and install Laya there. Nothing is written outside the runtime directory. */
|
|
96
105
|
function layaSetupCommand(stateDir, platform = process.platform) {
|
|
97
106
|
const dir = path.join(stateDir, 'laya');
|
|
107
|
+
const tools = path.join(stateDir, 'laya-tools');
|
|
98
108
|
const port = typesafe.BACKENDS.laya.port;
|
|
99
109
|
if (platform === 'win32') {
|
|
100
|
-
|
|
110
|
+
const uv = `& "${tools}\\uv.exe"`;
|
|
111
|
+
return `$env:UV_PYTHON_INSTALL_DIR="${tools}\\python"; $env:UV_CACHE_DIR="${tools}\\cache"; `
|
|
112
|
+
+ `if (-not (Test-Path "${dir}\\Scripts\\laya-serve.exe")) { `
|
|
113
|
+
+ `if (-not (Test-Path "${tools}\\uv.exe")) { $env:UV_UNMANAGED_INSTALL="${tools}"; irm ${UV_INSTALLER}.ps1 | iex }; `
|
|
114
|
+
+ `Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "${dir}"; ${uv} venv --python ${LAYA_PY} --python-preference only-managed "${dir}"; ${uv} pip install --python "${dir}\\Scripts\\python.exe" "${LAYA_PKG}" }; `
|
|
101
115
|
+ `$env:LAYA_HOST="127.0.0.1"; $env:LAYA_PORT="${port}"; $env:LAYA_DEVICE="cpu"; $env:LAYA_PRELOAD="1"; & "${dir}\\Scripts\\laya-serve"`;
|
|
102
116
|
}
|
|
103
|
-
|
|
117
|
+
const uv = `"${tools}/uv"`;
|
|
118
|
+
return `export UV_PYTHON_INSTALL_DIR="${tools}/python" UV_CACHE_DIR="${tools}/cache"; `
|
|
119
|
+
+ `[ -x "${dir}/bin/laya-serve" ] || { { [ -x ${uv} ] || curl -LsSf ${UV_INSTALLER}.sh | env UV_UNMANAGED_INSTALL="${tools}" sh; } && `
|
|
120
|
+
+ `rm -rf "${dir}" && ${uv} venv --python ${LAYA_PY} --python-preference only-managed "${dir}" && ${uv} pip install --python "${dir}/bin/python" "${LAYA_PKG}"; } && `
|
|
104
121
|
+ `LAYA_HOST=127.0.0.1 LAYA_PORT=${port} LAYA_DEVICE=cpu LAYA_PRELOAD=1 "${dir}/bin/laya-serve"`;
|
|
105
122
|
}
|
|
106
123
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.175.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.175.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -61,7 +61,9 @@ export function createDock(opts) {
|
|
|
61
61
|
const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
|
|
62
62
|
const inTree = (dir) => mine().filter((s) => s.tree === dir).sort((a, b) => (a.shown === 'exited') - (b.shown === 'exited') || String(b.created).localeCompare(String(a.created)));
|
|
63
63
|
/* a new session: the sheet asks name, agent and where it runs, starts it over the terminal connection and opens its terminal here (c3-new-session) */
|
|
64
|
-
const newSession = (on) => import('./c3-new-session.js').then((m) => m.openNewSession({ ws: S.ws, trees: (S.trees || []).filter((t) => t.ws === S.ws || t.main), on,
|
|
64
|
+
const newSession = (on) => import('./c3-new-session.js').then((m) => m.openNewSession({ ws: S.ws, boards: S.boards || [], trees: (S.trees || []).filter((t) => t.ws === S.ws || t.main), on,
|
|
65
|
+
// a session started on another board: the dock moves to that board, so the new session is in the tree
|
|
66
|
+
onStarted: (id, ws) => { if (ws && ws !== S.ws && S.boards && S.boards.some((w) => w.id === ws)) setWs(ws); S.sel = id; refresh(); } }));
|
|
65
67
|
const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); if (o.setOpen) o.setOpen(true); };
|
|
66
68
|
|
|
67
69
|
function row(s) {
|
|
@@ -172,13 +174,19 @@ export function createDock(opts) {
|
|
|
172
174
|
if (i >= 0) { e.preventDefault(); const n = items[i + (e.key === 'ArrowDown' ? 1 : -1)]; if (n) n.focus(); }
|
|
173
175
|
}
|
|
174
176
|
};
|
|
177
|
+
function setWs(ws) {
|
|
178
|
+
S.ws = ws; S.trees = null; S.sel = null; sel.value = ws; detail.removeAttribute('data-sel');
|
|
179
|
+
if (o.onWs) o.onWs(S.ws);
|
|
180
|
+
drawTree(); refresh();
|
|
181
|
+
}
|
|
175
182
|
let timer = null;
|
|
176
183
|
const api = {
|
|
177
184
|
side,
|
|
178
185
|
setWorkspaces(list, ws) {
|
|
179
186
|
S.ws = ws;
|
|
180
|
-
|
|
181
|
-
sel.
|
|
187
|
+
S.boards = list.length ? list : [{ id: ws, name: ws || 'workspace' }];
|
|
188
|
+
sel.replaceChildren(...S.boards.map((w) => el('option', { value: w.id, selected: w.id === ws }, w.name || w.id)));
|
|
189
|
+
sel.onchange = (e) => setWs(e.target.value);
|
|
182
190
|
},
|
|
183
191
|
start() { document.addEventListener('keydown', onKey); drawTree(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
|
|
184
192
|
stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/* pm/c3-new-session.js — start a session from console3, in a sheet, and open its terminal in the console (c3-new-session).
|
|
2
|
-
* openNewSession({ ws, trees, initiative, on, onStarted }) on: 'auto' (default), 'ws:<id>' (that tree) or 'new'
|
|
2
|
+
* openNewSession({ ws, boards, trees, initiative, on, onStarted }) on: 'auto' (default), 'ws:<id>' (that tree) or 'new'
|
|
3
|
+
* boards: the workspace's repositories ({ id, name }); the sheet's Board select picks one, and Runs on then lists that repository's trees.
|
|
4
|
+
* onStarted(id, ws): the new session's id and the board it started on
|
|
3
5
|
* Before this, every "start a session" press on console3 went to the classic app (/classic.html), because the page had no way
|
|
4
6
|
* to start one. A session starts over the terminal connection, so this uses the same request as the classic dock's new-session
|
|
5
7
|
* sheet (tab-term.js requestSpawn) and then opens the terminal with c3-session-term.js. Nothing opens unless the server says the
|
|
6
8
|
* session started; a refusal stays in the sheet as the server's sentence.
|
|
7
|
-
* Fields: name (empty: Shell or Claude, numbered when taken), agent (the Settings default first), and where it runs:
|
|
9
|
+
* Fields: name (empty: Shell or Claude, numbered when taken), agent (the Settings default first), the board (repository), and where it runs:
|
|
8
10
|
* Automatic the server's start policy picks the tree (the initiative's own working copy, or the main tree)
|
|
9
11
|
* a listed tree that working tree as it is
|
|
10
12
|
* New branch a new branch in its own working copy
|
|
@@ -47,23 +49,37 @@ export function spawnOpts(f, d) {
|
|
|
47
49
|
export function openNewSession(opts) {
|
|
48
50
|
const o = opts || {};
|
|
49
51
|
const d = sessionDefaults();
|
|
50
|
-
|
|
51
|
-
const F = { agent: CLIS.includes(d.cli) ? d.cli : 'shell', on: o.on && (o.on === 'new' || trees.some((t) => 'ws:' + t.ws === o.on)) ? o.on : 'auto', busy: false };
|
|
52
|
+
let trees = (o.trees || []).filter((t) => t && !t.missing && t.ws);
|
|
53
|
+
const F = { ws: o.ws, agent: CLIS.includes(d.cli) ? d.cli : 'shell', on: o.on && (o.on === 'new' || trees.some((t) => 'ws:' + t.ws === o.on)) ? o.on : 'auto', busy: false };
|
|
54
|
+
const boards = (o.boards || []).filter((w) => w && w.id);
|
|
55
|
+
if (o.ws && !boards.some((w) => w.id === o.ws)) boards.unshift({ id: o.ws, name: o.ws });
|
|
52
56
|
|
|
53
57
|
const name = el('input', { type: 'text', 'aria-label': 'Name', placeholder: 'Empty: Shell or Claude', 'data-c3ns': 'name' });
|
|
54
58
|
const agent = el('select', { 'aria-label': 'Agent', 'data-c3ns': 'agent', onchange: (e) => { F.agent = e.target.value; } },
|
|
55
59
|
...CLIS.map((c) => el('option', { value: c, selected: c === F.agent }, AGENT[c] || c)));
|
|
56
60
|
const branch = el('input', { type: 'text', 'aria-label': 'New branch', placeholder: 'feature/…', 'data-c3ns': 'branch' });
|
|
57
61
|
const branchRow = el('label', { class: 'c3ns-f', hidden: F.on !== 'new' }, el('span', { class: 'dim mono' }, 'New branch'), branch);
|
|
58
|
-
const where = el('select', { 'aria-label': 'Runs on', 'data-c3ns': 'on', onchange: (e) => { F.on = e.target.value; branchRow.hidden = F.on !== 'new'; } }
|
|
62
|
+
const where = el('select', { 'aria-label': 'Runs on', 'data-c3ns': 'on', onchange: (e) => { F.on = e.target.value; branchRow.hidden = F.on !== 'new'; } });
|
|
63
|
+
const fillWhere = () => where.replaceChildren(
|
|
59
64
|
el('option', { value: 'auto', selected: F.on === 'auto' }, o.initiative ? 'Automatic: the working copy of initiative/' + o.initiative : 'Automatic: the main working tree'),
|
|
60
65
|
...trees.map((t) => el('option', { value: 'ws:' + t.ws, selected: F.on === 'ws:' + t.ws }, (t.main ? 'Main tree' : '⑂') + ' ' + (t.branch || 'detached'))),
|
|
61
66
|
el('option', { value: 'new', selected: F.on === 'new' }, 'New branch'));
|
|
67
|
+
fillWhere();
|
|
68
|
+
// another board: its own working trees, read from the server; Runs on goes back to Automatic
|
|
69
|
+
const board = el('select', { 'aria-label': 'Board', 'data-c3ns': 'board', onchange: async (e) => {
|
|
70
|
+
F.ws = e.target.value; F.on = 'auto'; branchRow.hidden = true; trees = []; fillWhere();
|
|
71
|
+
const ws = F.ws;
|
|
72
|
+
const r = await pmGet('/api/git/worktrees?ws=' + encodeURIComponent(ws)).catch(() => null);
|
|
73
|
+
if (ws !== F.ws) return;
|
|
74
|
+
trees = (r && r.ok && Array.isArray(r.worktrees) ? r.worktrees : []).filter((t) => t && !t.missing && t.ws && (t.ws === ws || t.main));
|
|
75
|
+
fillWhere();
|
|
76
|
+
} }, ...boards.map((w) => el('option', { value: w.id, selected: w.id === F.ws }, w.name || w.id)));
|
|
62
77
|
const err = el('p', { class: 'c3ns-err', role: 'alert', 'data-c3ns': 'error' });
|
|
63
78
|
const go = button({ label: 'Start', variant: 'primary', size: 'sm', onClick: () => start(), attrs: { 'data-c3ns': 'start' } });
|
|
64
79
|
const body = el('div', { class: 'c3ns', 'data-c3ns': 'form' },
|
|
65
80
|
el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Name'), name),
|
|
66
81
|
el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Agent'), agent),
|
|
82
|
+
boards.length ? el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Board'), board) : null,
|
|
67
83
|
el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Runs on'), where),
|
|
68
84
|
branchRow, err, el('div', { class: 'c3ns-go' }, go));
|
|
69
85
|
const sheet = openSheet({ title: o.initiative ? 'New session for ' + o.initiative : 'New session', icon: 'term', body });
|
|
@@ -71,20 +87,20 @@ export function openNewSession(opts) {
|
|
|
71
87
|
|
|
72
88
|
async function start() {
|
|
73
89
|
if (F.busy) return;
|
|
74
|
-
if (!
|
|
90
|
+
if (!F.ws) { err.textContent = 'Pick a board first.'; return; }
|
|
75
91
|
F.busy = true; go.disabled = true; err.textContent = '';
|
|
76
92
|
const sessions = await pmGet('/api/sessions').then((r) => (r && Array.isArray(r.sessions) ? r.sessions : []), () => []);
|
|
77
93
|
const base = (AGENT[F.agent] || 'Shell') + (o.initiative ? ' · ' + o.initiative : '');
|
|
78
94
|
// the default profile is the server's; this browser's copy can be old, so a Claude start reads it first
|
|
79
95
|
const profile = F.agent === 'claude' ? adoptProfileDefault(await pmGet('/api/profiles').catch(() => null)) : null;
|
|
80
|
-
const req = spawnOpts({ name: name.value.trim() || freeName(base, sessions), agent: F.agent, initiative: o.initiative, profile, on: F.on, branch: branch.value, ws:
|
|
96
|
+
const req = spawnOpts({ name: name.value.trim() || freeName(base, sessions), agent: F.agent, initiative: o.initiative, profile, on: F.on, branch: branch.value, ws: F.ws }, d);
|
|
81
97
|
if (req.error) { err.textContent = req.error; F.busy = false; go.disabled = false; return; }
|
|
82
98
|
const term = await import('./tab-term.js').catch(() => null);
|
|
83
99
|
const res = term ? await term.requestSpawn(req) : { ok: false, error: 'The terminal connection could not be loaded on this page.' };
|
|
84
100
|
F.busy = false; go.disabled = false;
|
|
85
101
|
if (!res || !res.ok) { err.textContent = (res && res.error) || 'The session was not started.'; return; }
|
|
86
102
|
sheet.close();
|
|
87
|
-
if (o.onStarted) o.onStarted(res.id);
|
|
103
|
+
if (o.onStarted) o.onStarted(res.id, F.ws);
|
|
88
104
|
const m = await import('./c3-session-term.js');
|
|
89
105
|
m.openSessionTerm({ id: res.id, name: req.name });
|
|
90
106
|
// a session that shares its working tree with a running one is told so: their edits land in the same files
|
|
@@ -18,6 +18,26 @@ const store = {
|
|
|
18
18
|
};
|
|
19
19
|
export const SIZE_KEY = 'pmos.workspace.c3.term-size';
|
|
20
20
|
export const PAD_KEY = 'pmos.workspace.c3.term-pad';
|
|
21
|
+
export const PAD_H_KEY = 'pmos.workspace.c3.term-pad-h';
|
|
22
|
+
const PAD_ROWS = 4; // the default height
|
|
23
|
+
/** The pad's height in rows: at least 2, and at most what leaves the terminal its room in a column `room` px high with `lh` px rows. Pure, for Node. */
|
|
24
|
+
export const clampPad = (rows, room, lh) => Math.max(2, Math.min(Math.round(rows), Math.max(2, Math.floor((room - 200) / (lh || 18)))));
|
|
25
|
+
|
|
26
|
+
/* The grip above the pad: drag it (or press the up and down arrow keys on it) to give the pad or the terminal more room.
|
|
27
|
+
* The drag is read on the document, as the dock's grip is (dock.js): a press on a grip starts it, the moves and the release are the window's. */
|
|
28
|
+
const padGrips = new WeakMap(); // grip -> { get(), set(px) }
|
|
29
|
+
let padDrag = null;
|
|
30
|
+
if (typeof document !== 'undefined') document.addEventListener('pointerdown', (e) => {
|
|
31
|
+
const g = e.button === 0 && e.target && e.target.closest ? e.target.closest('[data-c3-pad="grip"]') : null;
|
|
32
|
+
const r = g && padGrips.get(g);
|
|
33
|
+
if (!r) return;
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
padDrag = { g, r, y: e.clientY, rows0: r.get() };
|
|
36
|
+
g.setAttribute('data-dragging', '');
|
|
37
|
+
});
|
|
38
|
+
if (typeof window !== 'undefined') window.addEventListener('pointermove', (e) => { if (padDrag) padDrag.r.set(padDrag.rows0 + (padDrag.y - e.clientY) / padDrag.r.lh()); });
|
|
39
|
+
const endPadDrag = () => { if (padDrag) { padDrag.g.removeAttribute('data-dragging'); padDrag.r.done(); padDrag = null; } };
|
|
40
|
+
if (typeof window !== 'undefined') { window.addEventListener('pointerup', endPadDrag); window.addEventListener('pointercancel', endPadDrag); }
|
|
21
41
|
/* the sheet widths: Normal is the 600px wide sheet; the others are classes in console3.css (desktop only) */
|
|
22
42
|
export const SIZES = [['normal', 'Normal', 'Normal'], ['half', 'Half', 'Half of the window'], ['two-thirds', '2/3', 'Two-thirds of the window'], ['whole', 'Whole', 'Whole window']];
|
|
23
43
|
export const sizeOf = (v) => (SIZES.some((x) => x[0] === v) ? v : 'normal');
|
|
@@ -53,7 +73,7 @@ function makePad(id, term, tab) {
|
|
|
53
73
|
const text = pending; pending = null;
|
|
54
74
|
pmPost('/api/scratchpad', { session: id, text }).catch(() => { /* the text stays in the box */ });
|
|
55
75
|
};
|
|
56
|
-
const ta = el('textarea', { class: 'c3t-pad-ta', rows:
|
|
76
|
+
const ta = el('textarea', { class: 'c3t-pad-ta', rows: String(PAD_ROWS), placeholder: 'Scratchpad. One pad for each session. Saved on this machine.', 'aria-label': 'Scratchpad', 'data-c3-pad': 'text',
|
|
57
77
|
oninput: () => { typed = true; pending = ta.value; if (timer) clearTimeout(timer); timer = setTimeout(flush, 500); sync(); } });
|
|
58
78
|
const send = c3btn({ class: 'pill', 'data-c3-pad': 'send', title: 'Type the pad into this session. Enter is not pressed.' }, () => {
|
|
59
79
|
if (!ta.value.trim()) return;
|
|
@@ -62,18 +82,38 @@ function makePad(id, term, tab) {
|
|
|
62
82
|
}, c3ic('out', 'sm'), 'Send to terminal');
|
|
63
83
|
const open0 = store.get(PAD_KEY) !== 'closed';
|
|
64
84
|
const box = el('div', { class: 'c3t-pad', 'data-c3-pad': 'box' });
|
|
85
|
+
const grip = el('div', { class: 'c3t-pad-grip', 'data-c3-pad': 'grip', role: 'separator', 'aria-orientation': 'horizontal', tabindex: '0',
|
|
86
|
+
'aria-label': 'Resize the scratchpad', 'aria-valuemin': '2', title: 'Drag to resize the scratchpad. Double-click for the default size.' });
|
|
87
|
+
// the height is the textarea's own rows, so no inline style; 0 is the default
|
|
88
|
+
let rows = Number(store.get(PAD_H_KEY)) || 0;
|
|
89
|
+
const room = () => { const w = box.parentNode; return w && w.getBoundingClientRect ? w.getBoundingClientRect().height : window.innerHeight; };
|
|
90
|
+
const lh = () => parseFloat(getComputedStyle(ta).lineHeight) || 18;
|
|
91
|
+
const applyH = () => { ta.rows = rows || PAD_ROWS; grip.setAttribute('aria-valuenow', String(rows || PAD_ROWS)); };
|
|
92
|
+
const resize = {
|
|
93
|
+
lh,
|
|
94
|
+
get: () => rows || PAD_ROWS,
|
|
95
|
+
set: (n) => { const next = clampPad(n, room(), lh()); if (next === (rows || PAD_ROWS)) return; rows = next; applyH(); term.refitVisible(); },
|
|
96
|
+
done: () => { store.set(PAD_H_KEY, rows ? String(rows) : ''); },
|
|
97
|
+
};
|
|
98
|
+
padGrips.set(grip, resize);
|
|
99
|
+
grip.addEventListener('dblclick', () => { rows = 0; applyH(); resize.done(); requestAnimationFrame(() => term.refitVisible()); });
|
|
100
|
+
grip.addEventListener('keydown', (e) => {
|
|
101
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
resize.set(resize.get() + (e.key === 'ArrowUp' ? 1 : -1)); resize.done();
|
|
104
|
+
});
|
|
65
105
|
const toggle = c3btn({ class: 'chip2 lk', 'data-c3-pad': 'toggle' }, () => { const on = box.dataset.open !== 'true'; store.set(PAD_KEY, on ? 'open' : 'closed'); set(on); },
|
|
66
106
|
c3ic('edit', 'sm'), 'Scratchpad');
|
|
67
107
|
function set(on) {
|
|
68
108
|
box.dataset.open = String(on);
|
|
69
109
|
toggle.setAttribute('aria-expanded', String(on));
|
|
70
110
|
toggle.title = on ? 'Hide the scratchpad. The text is kept.' : 'Show the scratchpad';
|
|
71
|
-
ta.hidden = !on;
|
|
111
|
+
ta.hidden = !on; grip.hidden = !on;
|
|
72
112
|
requestAnimationFrame(() => term.refitVisible());
|
|
73
113
|
}
|
|
74
114
|
function sync() { send.disabled = !ta.value.trim(); }
|
|
75
|
-
box.append(el('div', { class: 'c3t-pad-h' }, toggle, send), ta);
|
|
76
|
-
set(open0); sync();
|
|
115
|
+
box.append(grip, el('div', { class: 'c3t-pad-h' }, toggle, send), ta);
|
|
116
|
+
set(open0); sync(); applyH();
|
|
77
117
|
pmGet(url).then((r) => { if (!typed && r && typeof r.text === 'string') { ta.value = r.text; sync(); } }).catch(() => { /* an empty pad */ });
|
|
78
118
|
const onHide = () => flush();
|
|
79
119
|
window.addEventListener('pagehide', onHide);
|
|
@@ -1663,10 +1663,15 @@
|
|
|
1663
1663
|
/* the terminal plus its scratchpad (c3-session-term.js): the terminal shrinks to make room for the pad */
|
|
1664
1664
|
.c3 .c3t-tw{height:calc(100vh - 150px);min-height:240px;display:flex;flex-direction:column;gap:var(--sp-2);min-width:0}
|
|
1665
1665
|
.c3 .c3t-tw .c3t-term{height:auto;flex:1 1 0;min-height:120px}
|
|
1666
|
-
.c3 .c3t-pad{display:grid;gap:var(--sp-2);flex:none;border-top:1px solid var(--border);padding-top:var(--sp-2)}
|
|
1666
|
+
.c3 .c3t-pad{display:grid;gap:var(--sp-2);flex:none;border-top:1px solid var(--border);padding-top:var(--sp-2);position:relative}
|
|
1667
1667
|
.c3 .c3t-pad-h{display:flex;gap:var(--sp-2);align-items:center;justify-content:space-between}
|
|
1668
1668
|
.c3 .c3t-pad-h .pill:disabled{opacity:.35;cursor:not-allowed}
|
|
1669
|
-
.c3 .c3t-pad-ta{width:100%;min-height:
|
|
1669
|
+
.c3 .c3t-pad-ta{width:100%;min-height:48px;resize:none;font:var(--fs-2) var(--mono);box-sizing:border-box}
|
|
1670
|
+
.c3 .c3t-pad-grip{position:absolute;left:0;right:0;top:-7px;height:10px;cursor:row-resize;touch-action:none;display:flex;align-items:center;justify-content:center}
|
|
1671
|
+
.c3 .c3t-pad-grip::after{content:'';width:36px;height:3px;background:var(--border)}
|
|
1672
|
+
.c3 .c3t-pad-grip:focus-visible::after,.c3 .c3t-pad-grip[data-dragging]::after{background:var(--ring)}
|
|
1673
|
+
@media (hover: hover){.c3 .c3t-pad-grip:hover::after{background:var(--ring)}}
|
|
1674
|
+
.c3 .c3t-pad-grip[hidden]{display:none}
|
|
1670
1675
|
/* terminal sheet sizes, as the classic dock has them; the phone sheet is already full width */
|
|
1671
1676
|
.c3 .c3t-size{flex:none}
|
|
1672
1677
|
.c3 .c3t-size button{padding:4px 8px}
|
|
@@ -99,22 +99,23 @@ function pill(label, icon, onClick, attrs) {
|
|
|
99
99
|
if (icon) b.prepend(ic(icon, 'sm'));
|
|
100
100
|
return b;
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
/* A past (ended) session has nothing left to end: its control is Remove, which takes it off the list (the same route deletes its record). */
|
|
103
|
+
function endBtn(st, s, past) {
|
|
104
|
+
return pill(past ? 'Remove' : 'End the session', past ? 'trash' : 'stop', () => { st.confirm = st.confirm === s.id ? null : s.id; st.redraw(); },
|
|
105
|
+
{ 'data-end-session': s.id, 'aria-expanded': String(st.confirm === s.id), title: past ? 'Remove this session from the list' : 'End the session' });
|
|
105
106
|
}
|
|
106
|
-
function endConfirm(st, s) {
|
|
107
|
-
return el('div', { class: 'c3t-tray', role: 'group', 'aria-label': 'End this session?', 'data-end-confirm': s.id },
|
|
108
|
-
dim('End it? Any pending change stays a proposal.'),
|
|
109
|
-
pill('End', 'stop', () => endSession(st, s), { class: 'rec', 'data-end-yes': s.id }),
|
|
107
|
+
function endConfirm(st, s, past) {
|
|
108
|
+
return el('div', { class: 'c3t-tray', role: 'group', 'aria-label': past ? 'Remove this session?' : 'End this session?', 'data-end-confirm': s.id },
|
|
109
|
+
dim(past ? 'Remove it from the list? Its commits and branch stay.' : 'End it? Any pending change stays a proposal.'),
|
|
110
|
+
pill(past ? 'Remove' : 'End', past ? 'trash' : 'stop', () => endSession(st, s, past), { class: 'rec', 'data-end-yes': s.id }),
|
|
110
111
|
pill('Keep it', null, () => { st.confirm = null; st.redraw(); }, {}));
|
|
111
112
|
}
|
|
112
|
-
async function endSession(st, s) {
|
|
113
|
+
async function endSession(st, s, past) {
|
|
113
114
|
st.confirm = null;
|
|
114
|
-
if (st.src.demo) { toast('In the real workspace this ends the session.'); st.redraw(); return; }
|
|
115
|
+
if (st.src.demo) { toast(past ? 'In the real workspace this removes the session.' : 'In the real workspace this ends the session.'); st.redraw(); return; }
|
|
115
116
|
const r = await pmPost('/api/session/remove', { id: s.id });
|
|
116
|
-
if (r && r.ok) toast({ text: r.removed === false ? 'Asked it to end. Press End again to remove it.' : 'Ended. Any pending change stays a proposal.', timeout: 4200 });
|
|
117
|
-
else toast({ text: 'Not ended: ' + ((r && r.error) || 'the workspace server did not answer'), tone: 'error' });
|
|
117
|
+
if (r && r.ok) toast({ text: r.removed === false ? 'Asked it to end. Press End again to remove it.' : past ? 'Removed.' : 'Ended. Any pending change stays a proposal.', timeout: 4200 });
|
|
118
|
+
else toast({ text: (past ? 'Not removed: ' : 'Not ended: ') + ((r && r.error) || 'the workspace server did not answer'), tone: 'error' });
|
|
118
119
|
st.last = ''; st.redraw(); st.refresh();
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -125,10 +126,13 @@ function tlBody(st, s, card, meta) {
|
|
|
125
126
|
if (items && items.length) {
|
|
126
127
|
kids.push(el('div', { class: 'c3t-tl2', 'data-timeline': s.id }, items.map((e) => el('div', { class: 'tlr' }, el('span', { class: 'lab ' + (e.src === 'observed' ? 'l-obs' : e.src === 'you' ? 'l-you' : 'l-rep') }, e.src), el('span', null, e.text)))),
|
|
127
128
|
dim('Observed is what the CLI did. Reported is what the agent said, with labels. You is what you pressed.'));
|
|
128
|
-
} else kids.push(dim('
|
|
129
|
+
} else kids.push(dim('This session has ended. Open it to read its terminal.'));
|
|
129
130
|
const resume = tl && tl.resume.find((r) => r.run === s.id);
|
|
130
|
-
|
|
131
|
+
const cont = pill('Continue this session', 'run', null, { title: 'Resuming starts a session over the terminal connection. This page has no route for it.' });
|
|
132
|
+
cont.disabled = true;
|
|
133
|
+
kids.push(el('div', { class: 'c3t-tray' }, cont, openBtn({ id: s.id, openSession: st.openSession }), endBtn(st, s, true),
|
|
131
134
|
resume ? el('span', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, resume.command) : null));
|
|
135
|
+
if (st.confirm === s.id) kids.push(endConfirm(st, s, true));
|
|
132
136
|
return kids;
|
|
133
137
|
}
|
|
134
138
|
|
package/workspace/server.js
CHANGED
|
@@ -5949,7 +5949,12 @@ async function handleRequest(req, res) {
|
|
|
5949
5949
|
let body = {};
|
|
5950
5950
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5951
5951
|
const sess = sessions.get(String(body.id || ''));
|
|
5952
|
-
if (!sess)
|
|
5952
|
+
if (!sess) {
|
|
5953
|
+
// an ended session the server no longer holds (only its record on disk is left) is still listed in Past; removing it deletes that record
|
|
5954
|
+
const id = String(body.id || '');
|
|
5955
|
+
if (/^sess-\d+$/.test(id) && fs.existsSync(path.join(SESS_DIR, id + '.json'))) { unpersist(id); pushSessions(); return json(res, { ok: true, removed: true }); }
|
|
5956
|
+
return json(res, { ok: false, error: 'no such session' }, 404);
|
|
5957
|
+
}
|
|
5953
5958
|
if (sess.child) {
|
|
5954
5959
|
stopHeadless(sess);
|
|
5955
5960
|
for (let i = 0; i < 30 && sess.child; i++) await new Promise((r) => setTimeout(r, 100));
|