create-pmos 0.174.0 → 0.176.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.174.0",
3
+ "version": "0.176.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@7e5ec99
1
+ kernel-source: pmos@ad91884
2
2
  assembled: 2026-10-13
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-13T01:01:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T01:16:54Z"}
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:01:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T01:16:54Z"}
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:01:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T01:16:54Z"}
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"]');
@@ -162,6 +177,8 @@ function startShell(name) {
162
177
  /* ── D: End asks first, then ends ── */
163
178
  await page.click('[data-c3pg="agent-button"]');
164
179
  ok('D0 the panel opens again', await waitFor(page, (s) => !!document.querySelector(s), sel));
180
+ ok('D0b it opens on the session opened last: its row is open and marked', await waitFor(page, (i) => { const li = document.querySelector(`[data-c3t="mine"] [data-run="${i}"]`); const end = document.querySelector(`[data-end-session="${i}"]`); return !!li && li.hasAttribute('data-last-open') && !!end && end.offsetParent !== null; }, id)
181
+ && await page.evaluate((i) => localStorage.getItem('pmos.workspace.c3.last-session') === i, id));
165
182
  if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
166
183
  await page.click(`[data-end-session="${id}"]`);
167
184
  ok('D1 End asks first', await waitFor(page, (i) => !!document.querySelector(`[data-end-confirm="${i}"]`), id));
@@ -219,11 +236,16 @@ function startShell(name) {
219
236
  ok('F1 Started by me has a New button', await waitFor(page, () => !!document.querySelector('[data-c3t="new"]')));
220
237
  await page.click('[data-c3t="new"]');
221
238
  ok('F2 New opens the new-session sheet on this page', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
239
+ const boards = (await get('/api/workspaces')).workspaces || [];
240
+ const opts = await page.evaluate(() => [...document.querySelectorAll('[data-c3ns="board"] option')].map((o) => ({ v: o.value, on: o.selected })));
241
+ 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));
242
+ ok('F2c the dock\'s repository is chosen', opts.filter((o) => o.on).length === 1, JSON.stringify(opts));
222
243
  await page.fill('[data-c3ns="name"]', 'c3 new shell');
223
244
  await page.selectOption('[data-c3ns="agent"]', 'shell');
224
245
  await page.click('[data-c3ns="start"]');
225
246
  const made = await until(async () => ((await get('/api/sessions')).sessions || []).find((s) => !before.has(s.id) && s.name === 'c3 new shell'), 10000);
226
247
  ok('F3 Start starts the session on the server', !!made);
248
+ 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
249
  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
250
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
229
251
  await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
@@ -237,6 +259,22 @@ function startShell(name) {
237
259
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
238
260
  }
239
261
 
262
+ /* ── P: a past session can be removed, also one the server knows only from its record on disk ── */
263
+ if (await page.isVisible('[data-shell="sheet"] [aria-label="Close"]').catch(() => false)) await page.click('[data-shell="sheet"] [aria-label="Close"]');
264
+ if (!(await page.isVisible('[data-c3t="past"]').catch(() => false))) await page.click('[data-c3pg="agent-button"]');
265
+ const pastSel = '[data-c3t="past"] [data-run="sess-9901"]';
266
+ ok('P1 the ended session is listed in Past', await waitFor(page, (s) => !!document.querySelector(s), pastSel));
267
+ if (!(await page.isVisible('[data-end-session="sess-9901"]').catch(() => false))) await page.click(pastSel + ' .nb');
268
+ ok('P2 its open row has Remove', await waitFor(page, () => /Remove/.test((document.querySelector('[data-end-session="sess-9901"]') || {}).textContent || '')));
269
+ ok('P3 it has no line about routes', !/GET \/api/.test(await page.evaluate((s) => document.querySelector(s).textContent, pastSel)));
270
+ await page.click('[data-end-session="sess-9901"]');
271
+ ok('P4 Remove asks first', await waitFor(page, () => !!document.querySelector('[data-end-confirm="sess-9901"]')));
272
+ await page.click('[data-end-yes="sess-9901"]');
273
+ ok('P5 the record is deleted', !!(await until(async () => !fs.existsSync(path.join(sessDir, 'sess-9901.json')), 6000)));
274
+ ok('P6 the row leaves Past', await waitFor(page, (s) => !document.querySelector(s), pastSel));
275
+ 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);
276
+ ok('P7 an id that is not a session id is refused', bad === 404 || bad === 403, String(bad));
277
+
240
278
  ok('E1 the page threw no error', errors.length === 0, errors.join(' | '));
241
279
  return finish();
242
280
  })().catch((e) => { console.error(e); finish(1); });
@@ -2,13 +2,15 @@
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-12
5
+ timestamp: 2026-10-13
6
6
  ---
7
7
 
8
8
  # Sessions in console3: start, open, message, read and end your sessions
9
9
 
10
10
  Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
11
11
 
12
+ The panel opens on the session that you opened last. Its row is open, so you can read the conversation and send a message without searching for it. A session counts as opened when you open its row or its terminal.
13
+
12
14
  ## The dock
13
15
 
14
16
  The dock is folded when you first open the console. To open it, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu. The browser keeps your choice: when you leave the dock open, it opens on the Briefing next time. On the other tabs it is folded away, and no rail of buttons is left at the right edge.
@@ -23,11 +25,12 @@ You start a session in console3. You do not need the classic app.
23
25
  - Press **New** next to **Started by me** in the Sessions panel.
24
26
  2. The **New session** sheet opens. Type a name, or leave it empty to use `Shell` or `Claude`.
25
27
  3. Select the **Agent**: Shell or Claude. A Claude session uses the profile, the launch flags and the tier from Settings.
26
- 4. Select where the session runs in **Runs on**:
28
+ 4. Select the **Board**: the repository that the session works in. The repository that is selected in the dock is the default.
29
+ 5. Select where the session runs in **Runs on**. The list shows the working trees of the board that you selected:
27
30
  - **Automatic**: the server selects the tree.
28
31
  - A listed tree: the session runs in that working tree.
29
32
  - **New branch**: type the branch name. The session runs in its own working copy of the new branch.
30
- 5. Press **Start**. The session starts, and its terminal opens in a sheet on the same page.
33
+ 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
34
 
32
35
  If the server cannot start the session, the sheet shows the reason.
33
36
 
@@ -50,6 +53,7 @@ Below the terminal is a scratchpad. It keeps text for this session only. The tex
50
53
 
51
54
  - 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
55
  - 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.
56
+ - 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
57
  - Esc in the pad closes the panel. Esc in the terminal goes to the terminal.
54
58
 
55
59
  ### The size of the terminal
@@ -94,3 +98,13 @@ If the connection to the workspace server is down, the message is not sent. The
94
98
  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
99
 
96
100
  A session that is on a card has the same **End the session** control below its card.
101
+
102
+ ## Remove a past session
103
+
104
+ A session under **Past (7 days)** has ended. To remove it from the list:
105
+
106
+ 1. Press the row to open it.
107
+ 2. Press **Remove**.
108
+ 3. Press **Remove** to confirm, or press **Keep it** to cancel.
109
+
110
+ 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 install Laya into the PMOS data folder. 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.
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). A folder of its own avoids the system Python, which newer Macs refuse to install into. */
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
- return `if (-not (Test-Path "${dir}\\Scripts\\laya-serve.exe")) { python -m venv "${dir}"; & "${dir}\\Scripts\\python" -m pip install "laya[serve]" }; `
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
- return `[ -x "${dir}/bin/laya-serve" ] || { python3 -m venv "${dir}" && "${dir}/bin/python" -m pip install "laya[serve]"; } && `
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.174.0",
3
+ "version": "0.176.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.174.0",
9
+ "version": "0.176.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.174.0",
3
+ "version": "0.176.0",
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",
@@ -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, onStarted: (id) => { S.sel = id; refresh(); } }));
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
- sel.replaceChildren(...(list.length ? list : [{ id: ws, name: ws || 'workspace' }]).map((w) => el('option', { value: w.id, selected: w.id === ws }, w.name || w.id)));
181
- sel.onchange = (e) => { S.ws = e.target.value; S.trees = null; S.sel = null; detail.removeAttribute('data-sel'); if (o.onWs) o.onWs(S.ws); drawTree(); refresh(); };
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
- const trees = (o.trees || []).filter((t) => t && !t.missing && t.ws);
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 (!o.ws) { err.textContent = 'Pick a repository first.'; return; }
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: o.ws }, d);
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,30 @@ 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
+ /* the session the PM opened last (its terminal here, or its row in the Sessions panel): the panel opens on it */
23
+ export const LAST_SESSION_KEY = 'pmos.workspace.c3.last-session';
24
+ export const rememberSession = (id) => { if (id) store.set(LAST_SESSION_KEY, String(id)); };
25
+ export const lastSession = () => store.get(LAST_SESSION_KEY) || null;
26
+ const PAD_ROWS = 4; // the default height
27
+ /** 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. */
28
+ export const clampPad = (rows, room, lh) => Math.max(2, Math.min(Math.round(rows), Math.max(2, Math.floor((room - 200) / (lh || 18)))));
29
+
30
+ /* 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.
31
+ * 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. */
32
+ const padGrips = new WeakMap(); // grip -> { get(), set(px) }
33
+ let padDrag = null;
34
+ if (typeof document !== 'undefined') document.addEventListener('pointerdown', (e) => {
35
+ const g = e.button === 0 && e.target && e.target.closest ? e.target.closest('[data-c3-pad="grip"]') : null;
36
+ const r = g && padGrips.get(g);
37
+ if (!r) return;
38
+ e.preventDefault();
39
+ padDrag = { g, r, y: e.clientY, rows0: r.get() };
40
+ g.setAttribute('data-dragging', '');
41
+ });
42
+ if (typeof window !== 'undefined') window.addEventListener('pointermove', (e) => { if (padDrag) padDrag.r.set(padDrag.rows0 + (padDrag.y - e.clientY) / padDrag.r.lh()); });
43
+ const endPadDrag = () => { if (padDrag) { padDrag.g.removeAttribute('data-dragging'); padDrag.r.done(); padDrag = null; } };
44
+ if (typeof window !== 'undefined') { window.addEventListener('pointerup', endPadDrag); window.addEventListener('pointercancel', endPadDrag); }
21
45
  /* the sheet widths: Normal is the 600px wide sheet; the others are classes in console3.css (desktop only) */
22
46
  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
47
  export const sizeOf = (v) => (SIZES.some((x) => x[0] === v) ? v : 'normal');
@@ -53,7 +77,7 @@ function makePad(id, term, tab) {
53
77
  const text = pending; pending = null;
54
78
  pmPost('/api/scratchpad', { session: id, text }).catch(() => { /* the text stays in the box */ });
55
79
  };
56
- const ta = el('textarea', { class: 'c3t-pad-ta', rows: '4', placeholder: 'Scratchpad. One pad for each session. Saved on this machine.', 'aria-label': 'Scratchpad', 'data-c3-pad': 'text',
80
+ 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
81
  oninput: () => { typed = true; pending = ta.value; if (timer) clearTimeout(timer); timer = setTimeout(flush, 500); sync(); } });
58
82
  const send = c3btn({ class: 'pill', 'data-c3-pad': 'send', title: 'Type the pad into this session. Enter is not pressed.' }, () => {
59
83
  if (!ta.value.trim()) return;
@@ -62,18 +86,38 @@ function makePad(id, term, tab) {
62
86
  }, c3ic('out', 'sm'), 'Send to terminal');
63
87
  const open0 = store.get(PAD_KEY) !== 'closed';
64
88
  const box = el('div', { class: 'c3t-pad', 'data-c3-pad': 'box' });
89
+ const grip = el('div', { class: 'c3t-pad-grip', 'data-c3-pad': 'grip', role: 'separator', 'aria-orientation': 'horizontal', tabindex: '0',
90
+ 'aria-label': 'Resize the scratchpad', 'aria-valuemin': '2', title: 'Drag to resize the scratchpad. Double-click for the default size.' });
91
+ // the height is the textarea's own rows, so no inline style; 0 is the default
92
+ let rows = Number(store.get(PAD_H_KEY)) || 0;
93
+ const room = () => { const w = box.parentNode; return w && w.getBoundingClientRect ? w.getBoundingClientRect().height : window.innerHeight; };
94
+ const lh = () => parseFloat(getComputedStyle(ta).lineHeight) || 18;
95
+ const applyH = () => { ta.rows = rows || PAD_ROWS; grip.setAttribute('aria-valuenow', String(rows || PAD_ROWS)); };
96
+ const resize = {
97
+ lh,
98
+ get: () => rows || PAD_ROWS,
99
+ set: (n) => { const next = clampPad(n, room(), lh()); if (next === (rows || PAD_ROWS)) return; rows = next; applyH(); term.refitVisible(); },
100
+ done: () => { store.set(PAD_H_KEY, rows ? String(rows) : ''); },
101
+ };
102
+ padGrips.set(grip, resize);
103
+ grip.addEventListener('dblclick', () => { rows = 0; applyH(); resize.done(); requestAnimationFrame(() => term.refitVisible()); });
104
+ grip.addEventListener('keydown', (e) => {
105
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
106
+ e.preventDefault();
107
+ resize.set(resize.get() + (e.key === 'ArrowUp' ? 1 : -1)); resize.done();
108
+ });
65
109
  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
110
  c3ic('edit', 'sm'), 'Scratchpad');
67
111
  function set(on) {
68
112
  box.dataset.open = String(on);
69
113
  toggle.setAttribute('aria-expanded', String(on));
70
114
  toggle.title = on ? 'Hide the scratchpad. The text is kept.' : 'Show the scratchpad';
71
- ta.hidden = !on;
115
+ ta.hidden = !on; grip.hidden = !on;
72
116
  requestAnimationFrame(() => term.refitVisible());
73
117
  }
74
118
  function sync() { send.disabled = !ta.value.trim(); }
75
- box.append(el('div', { class: 'c3t-pad-h' }, toggle, send), ta);
76
- set(open0); sync();
119
+ box.append(grip, el('div', { class: 'c3t-pad-h' }, toggle, send), ta);
120
+ set(open0); sync(); applyH();
77
121
  pmGet(url).then((r) => { if (!typed && r && typeof r.text === 'string') { ta.value = r.text; sync(); } }).catch(() => { /* an empty pad */ });
78
122
  const onHide = () => flush();
79
123
  window.addEventListener('pagehide', onHide);
@@ -82,6 +126,7 @@ function makePad(id, term, tab) {
82
126
 
83
127
  export async function openSessionTerm({ id, name } = {}) {
84
128
  if (!id) return null;
129
+ rememberSession(id);
85
130
  if (!name) { const r = await pmGet('/api/sessions').catch(() => null); const x = r && Array.isArray(r.sessions) ? r.sessions.find((y) => y.id === id) : null; name = x ? x.name : id; }
86
131
  const [ok, term] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
87
132
  if (!ok || !term) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return null; }
@@ -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:72px;max-height:40vh;resize:vertical;font:var(--fs-2) var(--mono);box-sizing:border-box}
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}
@@ -16,6 +16,7 @@ import { toast } from './c3-toast.js';
16
16
  import { pmPost } from './http.js';
17
17
  import { button } from './primitives.js';
18
18
  import { chatFeed } from './c3-session-chat.js';
19
+ import { rememberSession, lastSession } from './c3-session-term.js';
19
20
 
20
21
  const live = new WeakMap();
21
22
  const DOT = { working: 'w', queued: 'w', asking: 'n', permission: 'n', attention: 'n', not_started: 'n', failed: 'b', quiet: '', paused: 'n', done: '' };
@@ -56,7 +57,7 @@ function row(st, s, card, parts) {
56
57
  li.style.gridTemplateColumns = 'auto minmax(0,1fr) auto';
57
58
  xp.style.gridColumn = '1 / -1';
58
59
  const key = 'r-' + s.id;
59
- attachExpand(li, xp, { key, initial: st.open.has(key), onToggle: (on) => { if (on) st.open.add(key); else st.open.delete(key); } });
60
+ attachExpand(li, xp, { key, initial: st.open.has(key), onToggle: (on) => { if (on) { st.open.add(key); rememberSession(s.id); } else st.open.delete(key); } });
60
61
  return li;
61
62
  }
62
63
  const dim = (t) => el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, t);
@@ -99,22 +100,23 @@ function pill(label, icon, onClick, attrs) {
99
100
  if (icon) b.prepend(ic(icon, 'sm'));
100
101
  return b;
101
102
  }
102
- function endBtn(st, s) {
103
- return pill('End the session', 'stop', () => { st.confirm = st.confirm === s.id ? null : s.id; st.redraw(); },
104
- { 'data-end-session': s.id, 'aria-expanded': String(st.confirm === s.id), title: 'End the session' });
103
+ /* 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). */
104
+ function endBtn(st, s, past) {
105
+ return pill(past ? 'Remove' : 'End the session', past ? 'trash' : 'stop', () => { st.confirm = st.confirm === s.id ? null : s.id; st.redraw(); },
106
+ { 'data-end-session': s.id, 'aria-expanded': String(st.confirm === s.id), title: past ? 'Remove this session from the list' : 'End the session' });
105
107
  }
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 }),
108
+ function endConfirm(st, s, past) {
109
+ return el('div', { class: 'c3t-tray', role: 'group', 'aria-label': past ? 'Remove this session?' : 'End this session?', 'data-end-confirm': s.id },
110
+ dim(past ? 'Remove it from the list? Its commits and branch stay.' : 'End it? Any pending change stays a proposal.'),
111
+ pill(past ? 'Remove' : 'End', past ? 'trash' : 'stop', () => endSession(st, s, past), { class: 'rec', 'data-end-yes': s.id }),
110
112
  pill('Keep it', null, () => { st.confirm = null; st.redraw(); }, {}));
111
113
  }
112
- async function endSession(st, s) {
114
+ async function endSession(st, s, past) {
113
115
  st.confirm = null;
114
- if (st.src.demo) { toast('In the real workspace this ends the session.'); st.redraw(); return; }
116
+ 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
117
  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' });
118
+ 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 });
119
+ else toast({ text: (past ? 'Not removed: ' : 'Not ended: ') + ((r && r.error) || 'the workspace server did not answer'), tone: 'error' });
118
120
  st.last = ''; st.redraw(); st.refresh();
119
121
  }
120
122
 
@@ -125,10 +127,13 @@ function tlBody(st, s, card, meta) {
125
127
  if (items && items.length) {
126
128
  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
129
  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('The timeline of what the CLI did, what the agent said and what you pressed is not served: GET /api/runs carries the cards only.'));
130
+ } else kids.push(dim('This session has ended. Open it to read its terminal.'));
129
131
  const resume = tl && tl.resume.find((r) => r.run === s.id);
130
- kids.push(el('div', { class: 'c3t-tray' }, el('button', { type: 'button', class: 'pill', disabled: true, title: 'Resuming starts a session over the terminal connection. This page has no route for it.' }, ic('run', 'sm'), 'Continue this session'), openBtn({ id: s.id, openSession: st.openSession }),
132
+ const cont = pill('Continue this session', 'run', null, { title: 'Resuming starts a session over the terminal connection. This page has no route for it.' });
133
+ cont.disabled = true;
134
+ kids.push(el('div', { class: 'c3t-tray' }, cont, openBtn({ id: s.id, openSession: st.openSession }), endBtn(st, s, true),
131
135
  resume ? el('span', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, resume.command) : null));
136
+ if (st.confirm === s.id) kids.push(endConfirm(st, s, true));
132
137
  return kids;
133
138
  }
134
139
 
@@ -176,7 +181,9 @@ export function mountSessions(host, ctx = {}) {
176
181
  disposeSessions(host);
177
182
  if (ctx && ctx.FIXTURE) return;
178
183
  ensureC3Css();
179
- const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(), confirm: null,
184
+ // the panel opens on the session the PM opened last: its row starts open, and the first draw brings it into view
185
+ const lastId = lastSession();
186
+ const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(lastId ? ['r-' + lastId] : []), confirm: null, reveal: lastId,
180
187
  openSession: (id) => { const r = st.data && st.data.groups ? [].concat(...Object.values(st.data.groups).filter(Array.isArray)).find((x) => x.id === id) : null;
181
188
  import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: r ? r.name : id })); } };
182
189
  if (st.src.demo) st.openSession = () => toast('In the real workspace this opens the CLI session.');
@@ -190,6 +197,11 @@ export function mountSessions(host, ctx = {}) {
190
197
  st.last = key; st.data = r;
191
198
  if (r.ok && ctx.onNeeds) ctx.onNeeds(needsYou(r));
192
199
  if (!busy()) draw(host, st); else st.last = '';
200
+ if (st.reveal && r.ok) {
201
+ const li = host.querySelector(`[data-run="${CSS.escape(st.reveal)}"]`);
202
+ st.reveal = null;
203
+ if (li) { li.setAttribute('data-last-open', ''); requestAnimationFrame(() => li.scrollIntoView({ block: 'nearest' })); }
204
+ }
193
205
  };
194
206
  st.timer = setInterval(() => { if (!busy()) st.refresh(); }, 5000);
195
207
  if (st.src.subscribe) st.unsub = st.src.subscribe(() => st.refresh());
@@ -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) return json(res, { ok: false, error: 'no such session' }, 404);
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));