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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.174.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@7e5ec99
1
+ kernel-source: pmos@128e902
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: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:01:21Z"}
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:01:21Z"}
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-12
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 where the session runs in **Runs on**:
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
- 5. Press **Start**. The session starts, and its terminal opens in a sheet on the same page.
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 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.175.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.175.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.175.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,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: '4', placeholder: 'Scratchpad. One pad for each session. Saved on this machine.', 'aria-label': 'Scratchpad', 'data-c3-pad': 'text',
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: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}
@@ -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
- 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' });
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('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.'));
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
- 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 }),
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
 
@@ -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));