create-pmos 0.146.0 → 0.147.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.146.0",
3
+ "version": "0.147.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@ec68387
1
+ kernel-source: pmos@1f1a8b3
2
2
  assembled: 2026-10-12
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-12T02:35:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:42:43Z"}
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-12T02:35:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:42:43Z"}
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-12T02:35:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:42:43Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-sessions-panel-test.js — the console3 Sessions panel (Agent surfaces): ending a session and opening one (initiative sessions-panel-end-and-open).
4
+ *
5
+ * Starts a real server with its own state directory. Before it starts, a session record is written whose process does not exist and
6
+ * whose file still says it runs: the server restores it as exited, and GET /api/runs must say it ended (it read "starting · 4 d" before).
7
+ * Then one live shell is started, and the page checks: the live row reads the server's state, not "starting"; Open the session draws the
8
+ * session's real terminal in a sheet on /c3.html and the address does not change; End asks first, Keep it keeps it, End ends it.
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawn } = require('child_process');
13
+ const { WebSocket } = require('ws');
14
+ const { findBrowser } = require('./lib/find-browser');
15
+ const T = require('./test-run').configure({ name: 'c3-sessions-panel' });
16
+
17
+ const BASE = T.BASE;
18
+ const results = [];
19
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
20
+ let srv = null; let browser = null;
21
+ const finish = async (code) => {
22
+ if (browser) await browser.close().catch(() => {});
23
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
24
+ T.killTmux();
25
+ const bad = results.filter((x) => !x.cond).length;
26
+ console.log(`\n${results.length - bad}/${results.length} c3-sessions-panel assertions passed`);
27
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
28
+ };
29
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ async function until(fn, ms = 8000) { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(150); } return null; }
32
+ async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
33
+ const get = (p) => fetch(BASE + p).then((r) => r.json());
34
+
35
+ function startShell(name) {
36
+ return new Promise((resolve) => {
37
+ const ws = new WebSocket(`ws://${T.HOST}:${T.PORT}`); const inbox = [];
38
+ ws.on('message', (raw) => { try { inbox.push(JSON.parse(raw)); } catch { /* ignore */ } });
39
+ ws.on('error', () => resolve(null));
40
+ ws.on('open', async () => {
41
+ ws.send(JSON.stringify({ t: 'new', agent: 'shell', name }));
42
+ const m = await until(() => inbox.find((x) => x.t === 'created' || x.t === 'error'), 8000);
43
+ ws.close(); resolve(m && m.t === 'created' ? m.id : null);
44
+ });
45
+ });
46
+ }
47
+
48
+ (async () => {
49
+ const exe = findBrowser();
50
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-sessions-panel suite cannot run'); return finish(1); }
51
+ let chromium;
52
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-sessions-panel suite cannot run'); return finish(1); }
53
+
54
+ /* a record left by a server that stopped: the file says it runs, its process is gone (no tmux session by that name) */
55
+ const sessDir = path.join(T.STATE_DIR, 'sessions');
56
+ fs.mkdirSync(sessDir, { recursive: true });
57
+ const created = new Date(Date.now() - 4 * 864e5).toISOString();
58
+ fs.writeFileSync(path.join(sessDir, 'sess-900.json'), JSON.stringify({ id: 'sess-900', name: 'stale claude', agent: 'shell', state: 'working', created, tmux: 'pmos-gone-900', origin: 'manual', cwd: path.resolve(__dirname, '..'), root: path.resolve(__dirname, '..') }));
59
+ fs.writeFileSync(path.join(sessDir, 'sess-900.scrollback'), '');
60
+
61
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
62
+ await T.waitForServer(BASE);
63
+
64
+ /* ── A: /api/runs believes the server, not a stale file ── */
65
+ const r0 = await get('/api/runs');
66
+ const inGroup = (r, g, id) => ((r.groups && r.groups[g]) || []).some((s) => s.id === id);
67
+ ok('A1 a session whose process is gone is not listed as started by me', r0.ok && !inGroup(r0, 'startedByMe', 'sess-900'), JSON.stringify(r0.groups && r0.groups.startedByMe));
68
+ ok('A2 it is listed in Past', inGroup(r0, 'past', 'sess-900'));
69
+
70
+ const id = await startShell('panel shell');
71
+ ok('A3 a live shell starts', !!id);
72
+ const r1 = await until(async () => { const r = await get('/api/runs'); return inGroup(r, 'startedByMe', id) ? r : null; });
73
+ const row = r1 && r1.groups.startedByMe.find((s) => s.id === id);
74
+ ok('A4 the live shell is started by me and carries the server state', !!row && typeof row.label === 'string' && row.label.length > 0, JSON.stringify(row));
75
+
76
+ browser = await chromium.launch({ executablePath: exe });
77
+ const page = await (await browser.newContext({ viewport: { width: 1440, height: 1000 } })).newPage();
78
+ const errors = [];
79
+ page.on('pageerror', (e) => errors.push(String(e && e.message || e)));
80
+
81
+ /* ── B: the panel ── */
82
+ await page.goto(BASE + '/c3.html');
83
+ ok('B1 the header has the Sessions button', await waitFor(page, () => !!document.querySelector('[data-c3pg="agent-button"]'), null, 45000));
84
+ await page.click('[data-c3pg="agent-button"]');
85
+ const sel = `[data-c3t="mine"] [data-run="${id}"]`;
86
+ ok('B2 the live shell has a row under Started by me', await waitFor(page, (s) => !!document.querySelector(s), sel));
87
+ const sub = await page.evaluate((s) => (document.querySelector(s + ' .chips') || {}).textContent || '', sel);
88
+ ok('B3 the row does not say starting', !!sub && !/^starting/.test(sub.trim()), sub);
89
+ await page.click(sel + ' .nb');
90
+ ok('B3b a plain session draws no empty "Starting" card', await page.evaluate((s) => !document.querySelector(s + ' [data-run-host]'), sel));
91
+ ok('B4 the open row has End the session', await waitFor(page, (i) => !!document.querySelector(`[data-end-session="${i}"]`), id));
92
+
93
+ /* ── C: Open the session stays on this page ── */
94
+ const url0 = page.url();
95
+ await page.click(`[data-blank-panel="${id}"] [data-open-session]`);
96
+ ok('C1 the session opens in a terminal sheet', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
97
+ ok('C2 the page stays on console3', page.url() === url0, page.url());
98
+ await sleep(800);
99
+ await page.click(`[data-c3-session-term="${id}"] .xterm`);
100
+ await page.keyboard.type('echo c3-typed-$((40+2))\n');
101
+ ok('C2b what is typed in the sheet reaches the shell', !!(await until(async () => /c3-typed-42/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
102
+ await page.keyboard.press('Escape');
103
+ await sleep(300);
104
+ ok('C3 Esc in the terminal goes to the shell, not the sheet', await page.evaluate((i) => !!document.querySelector(`[data-c3-session-term="${i}"]`), id));
105
+ await page.click('[data-shell="sheet"] [aria-label="Close"]');
106
+ ok('C4 the close button closes the terminal sheet', await waitFor(page, () => !document.querySelector('[data-c3-session-term]')));
107
+ ok('C5 closing the sheet leaves the session running', await until(async () => ((await get('/api/sessions')).sessions || []).some((s) => s.id === id && s.state !== 'exited')));
108
+
109
+ /* ── D: End asks first, then ends ── */
110
+ await page.click('[data-c3pg="agent-button"]');
111
+ ok('D0 the panel opens again', await waitFor(page, (s) => !!document.querySelector(s), sel));
112
+ if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
113
+ await page.click(`[data-end-session="${id}"]`);
114
+ ok('D1 End asks first', await waitFor(page, (i) => !!document.querySelector(`[data-end-confirm="${i}"]`), id));
115
+ await page.click(`[data-end-confirm="${id}"] button:not([data-end-yes])`);
116
+ ok('D2 Keep it keeps the session', await waitFor(page, (i) => !document.querySelector(`[data-end-confirm="${i}"]`), id) && ((await get('/api/sessions')).sessions || []).some((s) => s.id === id));
117
+ await page.click(`[data-end-session="${id}"]`);
118
+ await page.click(`[data-end-yes="${id}"]`);
119
+ const gone = async () => !((await get('/api/sessions')).sessions || []).some((s) => s.id === id);
120
+ if (!(await until(gone, 6000)) && await page.$(`[data-end-session="${id}"]`)) { await page.click(`[data-end-session="${id}"]`); await page.click(`[data-end-yes="${id}"]`); } // "asked it to end": the second press removes it
121
+ ok('D3 End ends the session', !!(await until(gone, 8000)));
122
+ ok('D4 its row leaves Started by me', await waitFor(page, (s) => !document.querySelector(s), sel));
123
+
124
+ ok('E1 the page threw no error', errors.length === 0, errors.join(' | '));
125
+ return finish();
126
+ })().catch((e) => { console.error(e); finish(1); });
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: the Calendar tab of the console page"
4
- description: "The Calendar tab of the console page (/c3.html): Calendar and Notes, the five zooms, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
4
+ description: "The Calendar tab of the console page (/c3.html): Calendar and Notes, the five zooms, the highlights, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
5
5
  timestamp: 2026-10-12
6
6
  ---
7
7
 
@@ -47,12 +47,27 @@ The chips under the zoom bar are the layers. Each chip shows the number of items
47
47
 
48
48
  All layers and Free time are on at the start. Tidy-up is off.
49
49
 
50
+ On Month, Quarter and Year there is one more chip, **Routine**. It shows the number of routine items in the range: report drafts, reports that go out daily or weekly, recurring meetings, stand-ups, 1:1s and due items on track. These items are counted, not listed. Press **Routine** to show every item as a marker, and press it again to go back to the highlights.
51
+
52
+ ## Highlights
53
+
54
+ Every zoom starts with a short summary above the grid, for example "October highlights". It names what matters in the range:
55
+
56
+ - the releases, and the milestones that are on track
57
+ - the key meetings, on Day, Week and Month only. A meeting is key when it is not routine. Month names the two biggest.
58
+ - the reports that go out
59
+ - **Watch:** the milestones and releases that are late or at risk, and the due items that are late
60
+
61
+ Press a name to open the item in the detail column. When nothing significant falls in the range, the summary says so.
62
+
50
63
  ## The grid
51
64
 
52
65
  - **Day** shows the timeline of the day. The markers of the day are in a strip above it.
53
66
  - **Week** shows a timeline for each day. Each day name has the hours of meetings of that day and a small bar. The bar has three parts: time tied to a promise or a decision, routine time, and the rest of a 6 hour day. The markers of each day are in a strip under the day name. Press a day name to open that day.
54
- - **Month** shows a cell for each day. A cell shows the number of meetings and up to three markers. "+2" is a button. It opens the day with the other markers. Press the day number to open the day.
55
- - **Quarter** shows a row for each week. **Year** shows a card for each month. Each shows the number of meetings and dated items, and the most important markers. A "+N" button opens the week or the month that holds the rest.
67
+ - **Month** shows a cell for each day. A cell shows the highlight of the day in one sentence, for example "SSO general release ships." Under it, a quiet line counts the rest, for example "1 due · 2 routine". Press that line or the day number to open the day. A line under the grid says what is counted and not named. With **Routine** on, a cell shows the number of meetings and up to three markers instead, and "+2" opens the day with the other markers.
68
+ - **Quarter** shows a column for each month. **Year** shows a column for each quarter, with a button for each of its three months. Each column shows its highlight and a quiet line, for example "24 meetings · 3 due · 9 routine". Quarter and Year are stricter than Month: they name only milestones, releases, reports that go out and late items. Press a column's title to open that month or quarter. With **Routine** on, a column shows its markers instead.
69
+
70
+ Titles wrap onto more lines. They are never cut.
56
71
 
57
72
  A marker has a symbol and a short title. A red edge means late or at risk of missing. An amber edge means at risk.
58
73
 
@@ -0,0 +1,35 @@
1
+ ---
2
+ type: Guide
3
+ title: "Sessions in console3: open, read and end your sessions"
4
+ description: "How the Sessions panel in console3 groups your sessions, how to open a session's terminal without leaving console3, and how to end a session."
5
+ timestamp: 2026-10-12
6
+ ---
7
+
8
+ # Sessions in console3: open, read and end your sessions
9
+
10
+ Press **Sessions** in the console3 header to open the panel. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
11
+
12
+ ## Read a row
13
+
14
+ Each row under **Started by me** shows the state of the session, how long ago it started, and its tree. The state comes from the workspace server, for example `working` or `quiet`.
15
+
16
+ If the server finds that the process of a session stopped (for example, after a restart), the session moves to **Past**.
17
+
18
+ ## Open a session
19
+
20
+ 1. Press the row to open it.
21
+ 2. Press **Open the session**.
22
+
23
+ The terminal of the session opens in a panel on the same page. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. To close the panel, press its close button. The session continues to run.
24
+
25
+ The **Open the session** link in the sessions dock opens the same panel.
26
+
27
+ ## End a session
28
+
29
+ 1. Press the row to open it.
30
+ 2. Press **End the session**.
31
+ 3. Press **End** to confirm, or press **Keep it** to cancel.
32
+
33
+ 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.
34
+
35
+ A session that is on a card has the same **End the session** control below its card.
@@ -69,4 +69,5 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
69
69
  - [Integrated work](integrated-work.md): how `pmos verify-work` turns a merged commit into Done, and what "Done · your call · not merged" means.
70
70
  - [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
71
71
  - [Reports: dashboards of reports](reports-dashboards.md): the console Reports page, its dashboards at portfolio, board and initiative scope, how to edit and share one as a draft, and the Drafts tab.
72
+ - [Sessions in console3](console3-sessions.md): the Sessions panel, how to open the terminal of a session on the same page, and how to end a session.
72
73
  - [Settings in console3](settings.md): how to open Settings, what each section holds, the scope tags, the environment notes, and where the settings are kept.
@@ -36,6 +36,9 @@ const SUGGEST_DAYS = 7;
36
36
  const WINDOW_MS = 48 * 3600000;
37
37
  const PER_KIND = { decision: 6, action: 6, moved: 8, prepared: 8, todo: 8 };
38
38
  const LIGHT_VIEWS = ['quarter', 'year'];
39
+ /* a report cadence that recurs more often than monthly is routine: the Calendar counts it instead of naming it (calendar-highlights) */
40
+ const ROUTINE_CADENCE = /\b(daily|weekly|bi-?weekly|fortnightly|stand-?up)\b/i;
41
+ const routineCadence = (c) => ROUTINE_CADENCE.test(String(c || ''));
39
42
 
40
43
  const pad = (n) => String(n).padStart(2, '0');
41
44
  const isDay = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
@@ -130,7 +133,7 @@ function layersFrom(dated, range, today) {
130
133
  const date = r.issued && isDay(r.issued.at) ? day10(r.issued.at) : day10(r.slot);
131
134
  n++;
132
135
  put('reports', { id: `report:${r.cadence}:${day10(r.slot)}`, date, kind: 'report', layer: 'reports', title: `${r.cadence} report${r.issued ? '' : ' (not issued)'}`,
133
- tone: r.issued ? 'ok' : null, ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
136
+ tone: r.issued ? 'ok' : null, cadence: r.cadence, routine: routineCadence(r.cadence), ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
134
137
  }
135
138
  for (const s of d.reportSchedules || []) {
136
139
  if (!s || !s.id) continue;
@@ -138,7 +141,7 @@ function layersFrom(dated, range, today) {
138
141
  if (!isDay(dt)) continue;
139
142
  n++;
140
143
  put('reports', { id: `report-sched:${s.id}:${day10(dt)}`, date: day10(dt), kind: 'report', layer: 'reports', title: `${str(s.name) || s.cadence || s.id} (scheduled)`, tone: null,
141
- ref: { type: 'report', id: s.cadence || s.id } });
144
+ ...(s.cadence ? { cadence: s.cadence } : {}), routine: routineCadence(s.cadence) || routineCadence(s.name), ref: { type: 'report', id: s.cadence || s.id } });
142
145
  }
143
146
  }
144
147
  if (!n) why('reports', 'no report file and no report schedule gives an issue date');
@@ -23,7 +23,10 @@ const { appendOutbox } = require(repoScript('outbox.js'));
23
23
  const prefixes = ['/api/runs'];
24
24
  const RUN_RE = /^sess-\d+$/;
25
25
 
26
- function sessionsOf(sessDir) {
26
+ // `live` (optional): the server's own session list (ctx.sessionList()), by id. The meta file on disk is not rewritten when a
27
+ // restart finds a session's process gone, so a file can still say it runs days later. When the list is given it decides:
28
+ // a session it calls exited, or does not hold at all, has ended. Its presented state and label ride along for the panel.
29
+ function sessionsOf(sessDir, live) {
27
30
  let names = [];
28
31
  try { names = fs.readdirSync(sessDir).filter((n) => /^sess-\d+\.json$/.test(n)); } catch { return []; }
29
32
  const out = [];
@@ -31,9 +34,12 @@ function sessionsOf(sessDir) {
31
34
  let meta; let st;
32
35
  try { const f = path.join(sessDir, n); meta = JSON.parse(fs.readFileSync(f, 'utf8')); st = fs.statSync(f); } catch { continue; }
33
36
  if (!meta || !meta.id) continue;
34
- const exited = meta.state === 'exited';
37
+ const row = live ? live.get(meta.id) : null;
38
+ const exited = meta.state === 'exited' || (live ? !row || row.state === 'exited' : false);
39
+ const exitedAt = Date.parse(meta.exitedAt);
35
40
  out.push({ id: meta.id, plan_item: meta.plan_item || null, name: meta.name || meta.id, origin: meta.origin || 'manual', cardId: meta.plan_item || null,
36
- startedAt: meta.created || null, endedAt: exited ? st.mtimeMs : null });
41
+ startedAt: meta.created || null, endedAt: exited ? (Number.isFinite(exitedAt) ? exitedAt : st.mtimeMs) : null,
42
+ shown: row ? row.shown || null : null, label: row ? row.label || null : null });
37
43
  }
38
44
  return out;
39
45
  }
@@ -61,7 +67,9 @@ function lastAtOf(lines) {
61
67
  }
62
68
 
63
69
  function view(ctx, now = Date.now()) {
64
- const sessions = sessionsOf(ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'));
70
+ let live = null;
71
+ if (typeof ctx.sessionList === 'function') { try { live = new Map(ctx.sessionList().map((r) => [r.id, r])); } catch { live = null; } }
72
+ const sessions = sessionsOf(ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'), live);
65
73
  const answers = runAnswers.readAnswers(runAnswers.answersFile(ctx.runtimeDir));
66
74
  const groups = groupSessions(sessions, now);
67
75
  const cards = {};
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.146.0",
3
+ "version": "0.147.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.146.0",
9
+ "version": "0.147.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.146.0",
3
+ "version": "0.147.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",
@@ -63,7 +63,7 @@ const setVar = (node, name, value) => { node.style.setProperty('--cal3-' + name,
63
63
  /* ── the page's own state on the instance: which layers are on, whether free time shows, what is selected ─────────── */
64
64
 
65
65
  function state(r) {
66
- const st = r.page || (r.page = { on: new Set(LAYERS.map((l) => l[0]).filter((id) => !(r.ctx && r.ctx.overlay && mem.hide.includes(id)))), free: true, tidy: false, sel: null });
66
+ const st = r.page || (r.page = { on: new Set(LAYERS.map((l) => l[0]).filter((id) => !(r.ctx && r.ctx.overlay && mem.hide.includes(id)))), free: true, tidy: false, routine: false, sel: null });
67
67
  if (r.sel) { st.sel = { type: 'event', id: r.sel }; r.sel = null; } // a meeting chosen on the Briefing (openDay)
68
68
  if (r.pendingMode) { st.mode = r.pendingMode; r.pendingMode = null; } // a note opened before the page drew (the Record chip)
69
69
  if (!st.mode) st.mode = 'cal';
@@ -146,7 +146,7 @@ export function drawCalendarPage(host, r, h) {
146
146
 
147
147
  const body = el('div', { class: 'calbody' + (r.anim && !r.loading ? ' ' + r.anim : '') },
148
148
  el('div', { class: 'cal3-wrap' },
149
- el('div', { class: 'cal3-main' }, empty, grid),
149
+ el('div', { class: 'cal3-main' }, empty, empty ? null : highlights(ui, range), grid),
150
150
  el('div', { class: 'cal3-side' }, detail(ui), st.tidy ? tidyList(ui) : null, st.free ? freeList(ui, range) : null, lanes(ui, range))));
151
151
  return pageBar(chips(ui, range), body);
152
152
  }
@@ -186,7 +186,8 @@ function chips(ui, range) {
186
186
  }, el('i', { class: 'cal3-sw cal3-sw-' + id }))),
187
187
  el('span', { class: 'cal3-sep', 'aria-hidden': 'true' }),
188
188
  chip('free', 'Free time', freeN, st.free, () => { st.free = !st.free; }, c3ic('clock', 'sm')),
189
- chip('tidy', 'Tidy-up', tidyCount(M, ui.d), st.tidy, () => { st.tidy = !st.tidy; }, c3ic('spark', 'sm')));
189
+ chip('tidy', 'Tidy-up', tidyCount(M, ui.d), st.tidy, () => { st.tidy = !st.tidy; }, c3ic('spark', 'sm')),
190
+ WIDE.includes(M.view) ? chip('routine', 'Routine', routineCount(ui, range), st.routine, () => { st.routine = !st.routine; }, c3ic('report', 'sm')) : null);
190
191
  }
191
192
  /* the number of suggestions in the range: the server's total (the list may be capped), or null when it sent none */
192
193
  const tidyCount = (M, d) => (M.tidy ? Math.max(0, (typeof d.tidy_total === 'number' ? d.tidy_total : M.tidy.length) - (d.tidy.length - M.tidy.length)) : null); // less the ones the PM dismissed
@@ -332,7 +333,123 @@ function freeBlock(ui, date, b, Y) {
332
333
  return n;
333
334
  }
334
335
 
335
- /* ── Month: a cell per day with the meeting count, up to three markers and +N, and the free count ───────────────── */
336
+ /* ── significance (initiative calendar-highlights) ───────────────────────────────────────────────────────────────
337
+ * Month, Quarter and Year show what matters and fold the rest into a count. Routine: a report date that recurs more often
338
+ * than monthly (the server's `routine` flag, or a daily, weekly or bi-weekly cadence), a meeting that repeats in the range,
339
+ * a stand-up or a 1:1, and a meeting tied to nothing. A due item that is on track is counted, not named. Key at Day, Week and
340
+ * Month: milestones, releases, reports that go out, late or at-risk due items, and meetings tied to a promise or a decision.
341
+ * Quarter and Year are one step stricter: milestones, releases, reports that go out, and late items; meetings are counted.
342
+ * The Routine chip (Month, Quarter, Year) brings back every item as markers. */
343
+ const WIDE = ['month', 'quarter', 'year'];
344
+ const FREQUENT = /\b(daily|weekly|bi-?weekly|fortnightly|stand-?up)\b/i;
345
+ const normT = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
346
+ const isDue = (m) => m.kind === 'due' || m.kind === 'own';
347
+ const routineMark = (m) => m.kind === 'report' && (m.routine === true || (m.routine !== false && (FREQUENT.test(String(m.cadence || '')) || FREQUENT.test(String(m.title || '')) || /\bdraft\b/i.test(String(m.title || '')))));
348
+ const quietDue = (m) => isDue(m) && m.tone !== 'bad' && m.tone !== 'warn';
349
+ const keyMark = (m) => !routineMark(m) && !quietDue(m);
350
+ const landMark = (m) => m.kind === 'milestone' || m.kind === 'release' || (m.kind === 'report' && !routineMark(m)) || (isDue(m) && m.tone === 'bad');
351
+ const shortDay = (h, date) => h.dayLabel(date).replace(/^\S+ /, '');
352
+
353
+ /* the meetings of the range, each marked key or routine; a title seen more than once in the range is a recurring meeting */
354
+ function meetingsKeyed(ui) {
355
+ const { M } = ui;
356
+ if (!M._keyed) {
357
+ const seen = new Map(); for (const m of M.meetings) { const k = normT(m.e.title); seen.set(k, (seen.get(k) || 0) + 1); }
358
+ const now = nowIso();
359
+ M._keyed = M.meetings.map((m) => {
360
+ const t = m.e.title || '';
361
+ const routine = m.e.routine === true || seen.get(normT(t)) > 1 || /stand-?up|\b1\s*[:/-]\s*1\b|one[- ]on[- ]one/i.test(t) || !evState(ui, m, now).tied;
362
+ return { ...m, key: m.e.key === true || !routine };
363
+ });
364
+ }
365
+ return M._keyed;
366
+ }
367
+ const meetingsIn = (ui, a, b) => (ui.st.on.has('meetings') ? meetingsKeyed(ui).filter((m) => m.date >= a && m.date <= b) : []);
368
+
369
+ /* the number of folded items in the range, for the Routine chip */
370
+ function routineCount(ui, range) {
371
+ const wide = ui.M.view !== 'month';
372
+ const marks = marksIn(ui.M, ui.st, range.start, range.end).filter((m) => (wide ? !landMark(m) : !keyMark(m))).length;
373
+ return marks + meetingsIn(ui, range.start, range.end).filter((m) => wide || !m.key).length;
374
+ }
375
+
376
+ /* a name inside a highlight: a link-styled button that selects the item, so its detail opens beside the grid */
377
+ function nameBtn(ui, sel, label, attrs) {
378
+ return button({ label, variant: 'ghost', attrs: { class: 'cal3-sl', ...attrs }, onClick: () => ui.select(sel, null) });
379
+ }
380
+ const markName = (ui, m, label) => nameBtn(ui, { type: 'marker', id: m.id }, label || m.title, { 'data-cal3-mk': m.id, 'data-cal3-layer': m.layer, 'data-cal3-kind': m.kind, title: kindOf(m)[0] + ': ' + m.title + ', ' + ui.h.dayLabel(m.date) });
381
+ const meetName = (ui, m) => nameBtn(ui, { type: 'event', id: m.e.id }, m.e.title || m.e.id, { 'data-cal3-hl-ev': m.e.id, title: (m.e.title || m.e.id) + ', ' + ui.h.dayLabel(m.date) });
382
+
383
+ /* join nodes as "a, b and c", with "N more" for what is past max */
384
+ function joinList(nodes, max) {
385
+ const a = nodes.slice(0, max); if (nodes.length > max) a.push(String(nodes.length - max) + ' more');
386
+ const out = [];
387
+ a.forEach((n, i) => { if (i) out.push(i === a.length - 1 ? ' and ' : ', '); out.push(n); });
388
+ return out;
389
+ }
390
+
391
+ /* the highlight of a span as a list of text and name nodes; [] when nothing in it is significant.
392
+ * mode 'cell' is the short form of a Month day; 'top' is the range summary and a Quarter or Year column. */
393
+ function highlightOf(ui, a, b, mode) {
394
+ const { h, M } = ui; const cell = mode === 'cell'; const wide = M.view === 'quarter' || M.view === 'year';
395
+ const marks = marksIn(M, ui.st, a, b); const meets = wide ? [] : meetingsIn(ui, a, b).filter((m) => m.key);
396
+ const on = (date) => (cell ? '' : ' on ' + shortDay(h, date));
397
+ const rel = marks.filter((m) => m.kind === 'release'); const ms = marks.filter((m) => m.kind === 'milestone' && (!m.tone || m.tone === 'ok'));
398
+ const rep = marks.filter((m) => m.kind === 'report' && !routineMark(m));
399
+ const lateMs = marks.filter((m) => m.kind === 'milestone' && (m.tone === 'bad' || m.tone === 'warn'));
400
+ const riskRel = rel.filter((m) => m.tone === 'bad' || m.tone === 'warn');
401
+ const lateDue = marks.filter((m) => isDue(m) && m.tone === 'bad'); const riskDue = wide ? [] : marks.filter((m) => isDue(m) && m.tone === 'warn');
402
+ const out = []; const sent = (...k) => { if (out.length) out.push(' '); out.push(...k); };
403
+ const dated = (list, max) => joinList(list.map((m) => el('span', null, markName(ui, m), on(m.date))), max);
404
+ if (rel.length) sent(...(cell ? [...joinList(rel.map((m) => markName(ui, m)), 2), rel.length === 1 ? ' ships.' : ' ship.'] : [plural(rel.length, 'release') + ': ', ...dated(rel, 3), '.']));
405
+ if (ms.length) sent(ms.length === 1 ? 'Milestone: ' : plural(ms.length, 'milestone') + ': ', ...dated(ms, 3), '.');
406
+ if (meets.length) {
407
+ if (cell || M.view === 'day') sent(...joinList(meets.map((m) => el('span', null, meetName(ui, m), h.timeOf(m.e.at) !== null ? ' at ' + h.hhmm(h.timeOf(m.e.at)) : '')), cell ? 2 : 4), '.');
408
+ else {
409
+ const big = meets.slice().sort((x, y) => hoursOf(y, h) - hoursOf(x, h) || String(x.e.at).localeCompare(String(y.e.at))).slice(0, 2).sort((x, y) => String(x.e.at).localeCompare(String(y.e.at)));
410
+ sent(plural(meets.length, 'key meeting') + (meets.length > big.length ? ', the biggest ' : ': '), ...joinList(big.map((m) => el('span', null, meetName(ui, m), ' on ' + shortDay(h, m.date))), 2), '.');
411
+ }
412
+ }
413
+ if (rep.length) sent(...(cell || rep.length <= 2 ? [...joinList(rep.map((m) => markName(ui, m, m.title.replace(/ goes out$/, ''))), 2), rep.length === 1 ? ' goes out.' : ' go out.'] : [rep.length + ' reports go out.']));
414
+ const watch = [];
415
+ for (const m of lateMs) watch.push(el('span', null, markName(ui, m, m.title.replace(/\s*\(late\)$/, '')), m.tone === 'bad' ? ' is late' : ' is at risk', cell ? '' : ' (' + shortDay(h, m.date) + ')'));
416
+ for (const m of riskRel) watch.push(el('span', null, markName(ui, m), m.tone === 'bad' ? ' is late' : ' is at risk'));
417
+ if (lateDue.length) watch.push(cell || lateDue.length <= 2 ? el('span', null, ...joinList(lateDue.map((m) => markName(ui, m)), 2), lateDue.length === 1 ? ' is late' : ' are late') : plural(lateDue.length, 'due item') + ' are late');
418
+ if (riskDue.length) watch.push(cell || riskDue.length <= 2 ? el('span', null, ...joinList(riskDue.map((m) => markName(ui, m)), 2), ' due soon') : plural(riskDue.length, 'due item') + ' due soon');
419
+ if (watch.length) sent(el('b', { class: 'cal3-watch' }, 'Watch:'), ' ', ...joinList(watch, 4), '.');
420
+ return out;
421
+ }
422
+
423
+ /* the range summary above the grid, at every zoom */
424
+ function highlights(ui, range) {
425
+ const { h, M } = ui;
426
+ const MF = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
427
+ const mo = Number(range.start.slice(5, 7));
428
+ const label = M.view === 'day' ? h.dayLabel(range.start) : M.view === 'week' ? 'Week of ' + h.dayLabel(range.start) : M.view === 'month' ? MF[mo - 1] : M.view === 'quarter' ? 'Q' + (Math.floor((mo - 1) / 3) + 1) : range.start.slice(0, 4);
429
+ const body = highlightOf(ui, range.start, range.end, 'top');
430
+ return el('div', { class: 'cal3-sum', 'data-cal3': 'highlights' },
431
+ el('span', { class: 'cal3-sum-h' }, label + ' highlights'),
432
+ el('p', null, ...(body.length ? body : ['Nothing significant. Only routine items fall in this range.'])));
433
+ }
434
+
435
+ /* the quiet line of a span: what is counted and not named; pressing it opens the span */
436
+ function quietLine(ui, a, b, view, opts) {
437
+ const wide = opts && opts.wide;
438
+ const marks = marksIn(ui.M, ui.st, a, b); const meets = meetingsIn(ui, a, b);
439
+ const due = marks.filter((m) => isDue(m) && (wide ? m.tone !== 'bad' : quietDue(m)));
440
+ const rt = marks.filter(routineMark).length + (wide ? 0 : meets.filter((m) => !m.key).length);
441
+ const nMeet = wide ? meets.length : 0;
442
+ const parts = [nMeet ? plural(nMeet, 'meeting') : null, due.length ? due.length + ' due' : null, rt ? rt + ' routine' : null].filter(Boolean);
443
+ if (!parts.length) return null;
444
+ const mo = Number(a.slice(5, 7));
445
+ const where = view === 'day' ? ui.h.dayLabel(a) : view === 'week' ? 'the week of ' + ui.h.dayLabel(a) : view === 'quarter' ? 'Q' + (Math.floor((mo - 1) / 3) + 1) + ' ' + a.slice(0, 4) : ui.h.MN[mo - 1] + ' ' + a.slice(0, 4);
446
+ const names = [...new Set([...due.map((m) => m.title), ...marks.filter(routineMark).map((m) => m.title)])];
447
+ const tip = parts.join(', ') + (names.length ? ': ' + names.slice(0, 8).join(', ') + (names.length > 8 ? ' and more' : '') : '') + '. Open ' + where;
448
+ return button({ label: parts.join(' · '), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-more cal3-rt', 'data-cal3-quiet': a, title: tip, 'aria-label': tip },
449
+ onClick: () => ui.h.go(ui.host, ui.r, { view, date: a, anim: 'zi' }) });
450
+ }
451
+
452
+ /* ── Month: a cell per day. Its highlight in a sentence and a quiet line; with Routine on, the meeting count and every marker ── */
336
453
 
337
454
  function monthGrid(ui) {
338
455
  const { M, st, h, d } = ui;
@@ -342,38 +459,58 @@ function monthGrid(ui) {
342
459
  for (let i = 0; i < off; i++) g.appendChild(el('div', { class: 'cal3-mc out', 'aria-hidden': 'true' }));
343
460
  for (const c of days) {
344
461
  const wd = h.dowOf(c.start); const n = st.on.has('meetings') ? M.meetingsOf(c).length : 0;
345
- g.appendChild(el('div', { class: 'cal3-mc' + (wd === 0 || wd === 6 ? ' we' : '') + (c.start === d.today ? ' today' : ''), 'data-cal3-date': c.start },
462
+ const hl = st.routine ? [] : highlightOf(ui, c.start, c.start, 'cell');
463
+ g.appendChild(el('div', { class: 'cal3-mc' + (wd === 0 || wd === 6 ? ' we' : '') + (c.start === d.today ? ' today' : '') + (hl.length ? ' key' : ''), 'data-cal3-date': c.start },
346
464
  button({ label: String(Number(c.start.slice(8))), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dn', 'data-cal3-day': c.start, 'aria-label': 'Open ' + h.dayLabel(c.start) + (n ? ', ' + plural(n, 'meeting') : ''), title: 'Open ' + h.dayLabel(c.start) }, onClick: () => h.go(ui.host, ui.r, { view: 'day', date: c.start, anim: 'zi' }) }),
347
- n ? el('span', { class: 'cal3-cnt' }, plural(n, 'meeting')) : null,
348
- ...markRow(ui, c.start, c.start, 3, true, 'day')));
465
+ ...(st.routine
466
+ ? [n ? el('span', { class: 'cal3-cnt' }, plural(n, 'meeting')) : null, ...markRow(ui, c.start, c.start, 3, true, 'day')]
467
+ : [hl.length ? el('p', { class: 'cal3-hl', 'data-cal3-hl': c.start }, ...hl) : null, quietLine(ui, c.start, c.start, 'day')])));
349
468
  }
350
- return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, g);
469
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, g, foldNote(ui));
470
+ }
471
+
472
+ /* the line under a Month, Quarter or Year grid that says what is counted and not named */
473
+ function foldNote(ui) {
474
+ if (ui.st.routine) return null;
475
+ return el('p', { class: 'dim cal3-fold', 'data-cal3': 'fold-note' }, (ui.M.view === 'month'
476
+ ? 'Report drafts, recurring meetings, stand-ups, 1:1s and due items on track are counted under each day, not listed.'
477
+ : 'Meetings, due items that are not late, and routine items are counted under each highlight, not listed. Open a month for its key meetings.')
478
+ + ' Turn on Routine to show everything.');
351
479
  }
352
480
 
353
- /* ── Quarter: a row per week; Year: a card per month. Counts, and the markers of the most important kinds ────────── */
481
+ /* ── Quarter: a column per month; Year: a column per quarter. Each has its highlight and its quiet line ──────────── */
354
482
 
355
- function spanCounts(ui, c) {
356
- const { M, st } = ui;
357
- const n = st.on.has('meetings') ? M.meetingsOf(c).length : 0;
358
- return el('div', { class: 'cal3-counts' },
359
- st.on.has('meetings') ? el('span', { class: 'tag' }, plural(n, 'meeting')) : null,
360
- el('span', { class: 'tag' }, plural(marksIn(M, st, c.start, c.end).length, 'dated item')));
483
+ const monthEnd = (start) => { const y = Number(start.slice(0, 4)); const m = Number(start.slice(5, 7)); const last = new Date(y, m, 0).getDate(); return start.slice(0, 8) + String(last).padStart(2, '0'); };
484
+ const monthStart = (y, m) => y + '-' + String(m).padStart(2, '0') + '-01';
485
+
486
+ function spanColumn(ui, a, b, head, now, extra, view) {
487
+ const hl = ui.st.routine ? null : highlightOf(ui, a, b, 'top');
488
+ return el('div', { class: 'cal3-ym cal3-col' + (now ? ' now' : ''), 'data-cal3-span': a }, head,
489
+ ui.st.routine ? el('div', { class: 'cal3-qmk' }, ...markRow(ui, a, b, 12, false, view))
490
+ : el('p', { class: 'cal3-hl' + (hl.length ? '' : ' dim'), 'data-cal3-hl': a }, ...(hl.length ? hl : ['Nothing significant.'])),
491
+ extra || null, ui.st.routine ? null : quietLine(ui, a, b, view, { wide: true }));
361
492
  }
362
493
 
363
494
  function quarterRows(ui) {
364
- const { M, h, d } = ui;
365
- const rows = M.cells.map((c) => el('div', { class: 'cal3-qrow' + (c.start <= d.today && d.today <= c.end ? ' now' : ''), 'data-cal3-week': c.start },
366
- button({ label: 'Week of ' + h.dayLabel(c.start), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', title: 'Open the week of ' + h.dayLabel(c.start) }, onClick: () => h.go(ui.host, ui.r, { view: 'week', date: c.start, anim: 'zi' }) }),
367
- spanCounts(ui, c), el('div', { class: 'cal3-qmk' }, ...markRow(ui, c.start, c.end, 4, false, 'week'))));
368
- return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-quarter', 'data-cal3': 'quarter' }, ...rows));
495
+ const { h, d } = ui; const range = d.range || { start: d.today, end: d.today };
496
+ const y = Number(range.start.slice(0, 4)); const m0 = Number(range.start.slice(5, 7));
497
+ const cols = [0, 1, 2].map((i) => {
498
+ const a = monthStart(y, m0 + i); const b = monthEnd(a);
499
+ const head = button({ label: h.MN[m0 + i - 1] + ' ' + y, variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', 'data-cal3-month': a.slice(0, 7), title: 'Open ' + h.MN[m0 + i - 1] + ' ' + y }, onClick: () => h.go(ui.host, ui.r, { view: 'month', date: a, anim: 'zi' }) });
500
+ return spanColumn(ui, a, b, head, a <= d.today && d.today <= b, null, 'month');
501
+ });
502
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-cols cal3-cols-3', 'data-cal3': 'quarter' }, ...cols), foldNote(ui));
369
503
  }
370
504
 
371
505
  function yearCards(ui) {
372
- const { M, h, d } = ui;
373
- const cards = M.cells.map((c) => el('div', { class: 'cal3-ym' + (c.start <= d.today && d.today <= c.end ? ' now' : ''), 'data-cal3-month': c.start.slice(0, 7) },
374
- button({ label: h.MN[Number(c.start.slice(5, 7)) - 1] + ' ' + c.start.slice(0, 4), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', title: 'Open the month' }, onClick: () => h.go(ui.host, ui.r, { view: 'month', date: c.start, anim: 'zi' }) }),
375
- spanCounts(ui, c), el('div', { class: 'cal3-qmk' }, ...markRow(ui, c.start, c.end, 3, false, 'month'))));
376
- return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-year', 'data-cal3': 'year' }, ...cards));
506
+ const { h, d } = ui; const range = d.range || { start: d.today, end: d.today }; const y = Number(range.start.slice(0, 4));
507
+ const cols = [0, 1, 2, 3].map((q) => {
508
+ const a = monthStart(y, q * 3 + 1); const b = monthEnd(monthStart(y, q * 3 + 3));
509
+ const head = button({ label: 'Q' + (q + 1) + ' ' + y + ' · ' + h.MN[q * 3] + ' to ' + h.MN[q * 3 + 2], variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', 'data-cal3-quarter': 'Q' + (q + 1), title: 'Open Q' + (q + 1) + ' ' + y }, onClick: () => h.go(ui.host, ui.r, { view: 'quarter', date: a, anim: 'zi' }) });
510
+ const months = el('div', { class: 'cal3-mlinks' }, ...[1, 2, 3].map((k) => { const s = monthStart(y, q * 3 + k); return button({ label: h.MN[q * 3 + k - 1], variant: 'ghost', size: 'sm', attrs: { class: 'cal3-more', 'data-cal3-month': s.slice(0, 7), title: 'Open ' + h.MN[q * 3 + k - 1] + ' ' + y }, onClick: () => h.go(ui.host, ui.r, { view: 'month', date: s, anim: 'zi' }) }); }));
511
+ return spanColumn(ui, a, b, head, a <= d.today && d.today <= b, months, 'quarter');
512
+ });
513
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-cols cal3-cols-4', 'data-cal3': 'year' }, ...cols), foldNote(ui));
377
514
  }
378
515
 
379
516
  /* ── the detail column ────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -39,12 +39,12 @@ const DATED = [
39
39
  ['2026-10-13', 'own', 'mine', 'Share returns insights with sales', null, item('item-demo017'), 'returns'],
40
40
  ['2026-11-16', 'own', 'mine', 'Write the Q1 discovery plan', null, item('item-demo020'), null],
41
41
  ];
42
- /* the weekly status to leadership is a report date every Friday */
43
- for (let d = '2026-09-04'; d <= '2026-12-25'; d = addDays(d, 7)) DATED.push([d, 'report', 'reports', 'Weekly status to leadership', null, { type: 'report', id: 'leadership-weekly' }, null]);
42
+ /* the weekly status to leadership is a report date every Friday: routine, so Month, Quarter and Year count it (calendar-highlights) */
43
+ for (let d = '2026-09-04'; d <= '2026-12-25'; d = addDays(d, 7)) DATED.push([d, 'report', 'reports', 'Weekly status to leadership', null, { type: 'report', id: 'leadership-weekly' }, null, { cadence: 'weekly', routine: true }]);
44
44
 
45
45
  function layersIn(start, end) {
46
46
  return DATED.filter((x) => x[0] >= start && x[0] <= end).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
47
- .map(([date, kind, layer, title, tone, ref, initiative], i) => ({ id: 'dated-' + date + '-' + i, date, kind, layer, title, tone, ref, ...(initiative ? { initiative } : {}) }));
47
+ .map(([date, kind, layer, title, tone, ref, initiative, extra], i) => ({ id: 'dated-' + date + '-' + i, date, kind, layer, title, tone, ref, ...(initiative ? { initiative } : {}), ...(extra || {}) }));
48
48
  }
49
49
 
50
50
  /* the suggested uses of a free block, from the demo's work items; a block past the first of a day gets none */
@@ -104,7 +104,8 @@ export function createDock(opts) {
104
104
  el('div', { class: 'chips2' }, el('b', { style: 'font-size:var(--fs-3)' }, s.name), el('span', { class: 'chips2 dim mono', style: 'font-size:var(--fs-1)' }, c3ic(PI[s.agent] || 'term', 'sm'), (PN[s.agent] || s.agent) + ' · ' + ((t && t.branch) || 'no branch'))),
105
105
  el('div', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, facts),
106
106
  s.shown === 'needs' ? el('div', { class: 'fold a' }, c3ic('alert', 'sm'), 'needs you') : null,
107
- el('a', { class: 'chip2 lk', href: '/', 'data-c3pg': 'dock-open-session', title: TIPS.terminalOpen, 'aria-label': 'Open the session ' + s.name + ' in the main app' }, ic('term', 'sm'), 'Open the session'),
107
+ el('a', { class: 'chip2 lk', href: '/', 'data-c3pg': 'dock-open-session', title: TIPS.terminalOpen, 'aria-label': 'Open the session ' + s.name,
108
+ onclick: (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button > 0) return; e.preventDefault(); import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: s.id, name: s.name })); } }, ic('term', 'sm'), 'Open the session'),
108
109
  term,
109
110
  el('div', { class: 'form' },
110
111
  el('input', { type: 'text', readonly: true, 'data-off': '', placeholder: s.shown === 'needs' ? 'Your answer' : 'Message', 'aria-label': 'Message', title: MORE, 'data-c3pg': 'dock-msg' }),
@@ -0,0 +1,44 @@
1
+ /* pm/c3-session-term.js — a session's real terminal inside console3, in a sheet, so "Open the session" no longer leaves /c3.html.
2
+ * openSessionTerm({ id, name })
3
+ * The terminal is tab-term.js's own (the one the main app draws): the same socket, attach, resize and input. Closing the sheet detaches the
4
+ * pane and leaves the session running, as closing a tab in the main app does. xterm and its addons are the main app's vendored globals,
5
+ * loaded here on the first open because /c3.html does not load them up front. */
6
+ import { el } from './dom.js';
7
+ import { openSheet } from './c3-sheet.js';
8
+ import { toast } from './c3-toast.js';
9
+ import { pmGet } from './http.js';
10
+
11
+ const VENDOR = ['/vendor/xterm.js', '/vendor/addon-fit.js', '/vendor/addon-web-links.js'];
12
+ let vendor = null;
13
+
14
+ function script(src) {
15
+ return new Promise((resolve) => {
16
+ if (document.querySelector('script[src="' + src + '"]')) { resolve(true); return; }
17
+ const s = document.createElement('script');
18
+ s.src = src; s.onload = () => resolve(true); s.onerror = () => resolve(false);
19
+ document.head.append(s);
20
+ });
21
+ }
22
+ function loadVendor() {
23
+ if (typeof window.Terminal === 'function') return Promise.resolve(true);
24
+ if (!vendor) {
25
+ if (!document.querySelector('link[href="/vendor/xterm.css"]')) document.head.append(el('link', { rel: 'stylesheet', href: '/vendor/xterm.css' }));
26
+ // the addons read window.Terminal when they run, so xterm goes first
27
+ vendor = script(VENDOR[0]).then((ok) => (ok ? Promise.all(VENDOR.slice(1).map(script)).then(() => true) : false));
28
+ }
29
+ return vendor;
30
+ }
31
+
32
+ export async function openSessionTerm({ id, name } = {}) {
33
+ if (!id) return null;
34
+ 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; }
35
+ const [ok, term] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
36
+ if (!ok || !term) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return null; }
37
+ const tab = { id: 'c3-session:' + id, kind: 'term', sessionId: id, spawn: false, sess: id };
38
+ const host = el('div', { class: 'c3t-term', 'data-c3-session-term': id });
39
+ // Esc and Tab belong to the shell while the terminal has focus (Esc interrupts an agent); the close button and a press outside still close
40
+ const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body: host, onClose: () => term.disposeTerm(tab), passKeys: (e) => host.contains(e.target) });
41
+ term.mountTerm(host, tab);
42
+ requestAnimationFrame(() => { term.refitVisible(); term.focusTerm(tab); });
43
+ return sheet;
44
+ }
@@ -1,7 +1,7 @@
1
1
  /* pm/c3-sheet.js — the console3 sheet, as a primitive (console3.html: bsHtml line 2502, the .sheet rules 143 to 145, 180, 496 to 502,
2
2
  * the Esc handler 2536 and the phone drag 2538 to 2540).
3
3
  *
4
- * openSheet({ title, icon, body, wide, dw, scrim, onClose }) -> { close, root, sheet, body }
4
+ * openSheet({ title, icon, body, wide, dw, scrim, onClose, passKeys }) -> { close, root, sheet, body } passKeys(e): true leaves that key to the page
5
5
  * closeSheet() closes the open sheet, if any
6
6
  *
7
7
  * On a desktop window the sheet is the right-hand panel (340px; `wide` 600px; `dw` the document sheet, up to 780px) over a
@@ -50,7 +50,7 @@ export function closeSheet() {
50
50
  if (typeof c.onClose === 'function') { try { c.onClose(); } catch { /* the caller reports its own errors */ } }
51
51
  }
52
52
 
53
- export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = false, scrim = true, onClose } = {}) {
53
+ export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = false, scrim = true, onClose, passKeys } = {}) {
54
54
  closeSheet();
55
55
  ensureC3Css();
56
56
  const opener = document.activeElement && document.activeElement !== document.body ? document.activeElement : null;
@@ -91,6 +91,7 @@ export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = f
91
91
  sheet.addEventListener('pointercancel', () => { if (drag) { drag = null; sheet.classList.remove('dragging'); sheet.style.transform = ''; } });
92
92
 
93
93
  const onKey = (e) => {
94
+ if (typeof passKeys === 'function' && passKeys(e)) return; // a terminal in the sheet owns Esc and Tab while it has focus
94
95
  if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close(); return; }
95
96
  if (e.key !== 'Tab') return;
96
97
  const items = [...sheet.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetWidth > 0 || n.offsetHeight > 0 || n === document.activeElement);
@@ -424,7 +424,8 @@ function dock() {
424
424
  onCount: (live, need) => { S.termBdg.textContent = String(need); S.termBdg.hidden = need === 0; import('./c3-terminal-icons.js').then((m) => { const t = m.TIPS.agentHeader(need); S.termBtn.title = t; S.termBtn.setAttribute('aria-label', t); }); }, // the badge counts runs that need you, never running ones
425
425
  });
426
426
  S.dockEl = S.dock.side; S.rail = S.dock.rail;
427
- window.addEventListener('pmos:open-session', () => location.assign('/'));
427
+ // a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
428
+ window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
428
429
  return [S.dock.side, S.dock.rail];
429
430
  }
430
431
  /* The mockup opens the dock on the Briefing and folds it to a rail on the other tabs (console3.html line 2017). */
@@ -7,7 +7,7 @@
7
7
  * needsYou(runs) is the one count for the needs-you badge: a run that asks, holds a permission, needs the terminal or did not start. */
8
8
  import { pmGet, pmPost } from './http.js';
9
9
 
10
- export const WHY_PROMPT = 'Sending a prompt to a session is not available on this page: no safe route sends one. The session\'s terminal is in the main app.';
10
+ export const WHY_PROMPT = 'Sending a prompt to a session is not available on this page: no safe route sends one. To type to the session, press Open the session.';
11
11
 
12
12
  const real = {
13
13
  demo: false, canPrompt: false, why: WHY_PROMPT,
@@ -15,7 +15,7 @@ export const TIPS = {
15
15
  agentHeader: (n) => (n ? 'Agent surfaces: ' + n + (n === 1 ? ' session needs you' : ' sessions need you') : 'Agent surfaces: sessions, results to accept, questions for you'),
16
16
  agentDock: 'Agent surfaces: the full page of sessions that need you',
17
17
  terminalRail: 'Sessions dock (Ctrl J): the terminals of your branches',
18
- terminalOpen: 'Open the session: its real terminal, in the main app',
18
+ terminalOpen: 'Open the session: its real terminal, on this page',
19
19
  };
20
20
 
21
21
  const NS = 'http://www.w3.org/2000/svg';
@@ -628,10 +628,32 @@
628
628
  .c3 .cal3-counts{display:flex;gap:4px;flex-wrap:wrap}
629
629
  .c3 .cal3-year{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
630
630
  .c3 .cal3-ym{border:1px solid var(--border);border-radius:8px;padding:8px;display:grid;gap:6px;align-content:start;min-width:0}
631
+ /* calendar-highlights: the range summary above the grid, a highlight per Month day, per Quarter month and per Year quarter */
632
+ .c3 .cal3-sum{display:grid;gap:4px;margin:0 0 12px;padding:8px 12px;border-left:3px solid var(--acc);border-radius:8px;background:var(--muted)}
633
+ .c3 .cal3-sum-h{font:var(--fs-1) var(--mono);color:var(--muted-foreground);text-transform:uppercase;letter-spacing:.06em}
634
+ .c3 .cal3-sum p{margin:0;font-size:var(--fs-3);line-height:1.55;color:var(--foreground)}
635
+ .c3 .cal3-hl{margin:2px 2px 0;font-size:var(--fs-1);line-height:1.45;color:var(--foreground);overflow-wrap:anywhere}
636
+ .c3 .cal3-col .cal3-hl{font-size:var(--fs-2);line-height:1.55}
637
+ .c3 .cal3 button.cal3-sl{display:inline;height:auto;min-height:0;padding:0;border:0;background:none;font:inherit;font-weight:600;line-height:inherit;color:inherit;white-space:normal;text-align:left;cursor:pointer;text-decoration:underline;text-decoration-color:var(--border);text-underline-offset:3px}
638
+ @media (hover:hover){.c3 .cal3 button.cal3-sl:hover{text-decoration-color:currentColor}}
639
+ .c3 .cal3 button.cal3-sl:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
640
+ .c3 .cal3-watch{color:var(--destructive)}
641
+ .c3 .cal3 button.cal3-more.cal3-rt{justify-content:flex-start;margin-top:auto;text-align:left}
642
+ .c3 .cal3-cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
643
+ .c3 .cal3-cols.cal3-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}
644
+ .c3 .cal3-col{padding:10px;gap:8px;min-height:160px}
645
+ .c3 .cal3-mlinks{display:flex;gap:4px;flex-wrap:wrap}
646
+ .c3 .cal3-fold{margin:8px 4px 0;font-size:var(--fs-2)}
647
+ /* titles wrap instead of being cut, in Month, Quarter and Year */
648
+ .c3 .cal3-mc button.cal3-mk,.c3 .cal3-qmk button.cal3-mk{white-space:normal;align-items:flex-start}
649
+ .c3 .cal3-mc button.cal3-mk>span,.c3 .cal3-qmk button.cal3-mk>span{align-items:flex-start;overflow:visible}
650
+ .c3 .cal3-mc .cal3-mk .cal3-mk-t,.c3 .cal3-qmk .cal3-mk .cal3-mk-t{overflow:visible;text-overflow:clip;overflow-wrap:anywhere}
631
651
  @container (max-width:640px){
632
652
  .c3 .cal3-mk.compact .cal3-mk-t{display:none}
633
653
  .c3 .cal3-mc{min-height:64px;padding:2px}
634
654
  .c3 .cal3-qrow{grid-template-columns:minmax(0,1fr)}
655
+ .c3 .cal3-cols,.c3 .cal3-cols.cal3-cols-4{grid-template-columns:minmax(0,1fr)}
656
+ .c3 .cal3-col{min-height:0}
635
657
  }
636
658
  /* the detail column */
637
659
  .c3 .cal3-dt{display:flex;align-items:center;gap:8px}
@@ -1628,6 +1650,8 @@
1628
1650
  .c3 .c3t-sw[aria-checked="true"] i{background:var(--acc)}.c3 .c3t-sw[aria-checked="true"] i::after{transform:translateX(16px)}
1629
1651
  .c3 .c3t-sw:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
1630
1652
  .c3 .c3t-panel{display:grid;gap:var(--sp-2);border:1px solid var(--border);border-radius:10px;padding:var(--sp-3)}
1653
+ /* a session's terminal in a sheet (c3-session-term.js) */
1654
+ .c3 .c3t-term{height:calc(100vh - 150px);min-height:240px;display:flex;flex-direction:column}
1631
1655
  .c3 .c3t-panel .feed{display:flex;flex-direction:column;gap:var(--sp-2);max-height:220px;overflow:auto}
1632
1656
  .c3 .c3t-panel .m{padding:6px 12px;border-radius:12px;max-width:88%;overflow-wrap:anywhere}
1633
1657
  .c3 .c3t-panel .m.me{align-self:flex-end;background:var(--acc);color:var(--on-accent)}.c3 .c3t-panel .m.bot{background:var(--muted)}
@@ -12,6 +12,7 @@
12
12
  * would send a prompt to the session (a suggestion, a revision, the free line, the mic) is drawn disabled with the reason: ctx.src.canPrompt.
13
13
  * Per-run UI state (the open tray, the Revise chips, the flipped face) lives in `UI`, outside the render, so a poll never closes it. */
14
14
  import { el } from './dom.js';
15
+ import { button } from './primitives.js';
15
16
  import { ic } from './c3-terminal-icons.js';
16
17
  import { ensureC3Css } from './pm-work-view.js';
17
18
  import { attachSwipe } from './c3-swipe.js';
@@ -100,7 +101,9 @@ function pausedBox(ctx, card) {
100
101
  el('div', { class: 'prog', 'aria-hidden': 'true' }, el('i', { class: 'd' }), el('i', { class: 'd' }), el('i'), el('i')), el('div', { class: 'c3t-tray' }, openBtn(ctx)));
101
102
  }
102
103
  export function openBtn(ctx) {
103
- return el('button', { type: 'button', class: 'pill c3t-open', 'data-open-session': '', title: 'Open the session: its real terminal, in the main app', onclick: () => ctx.openSession && ctx.openSession(ctx.id) }, ic('term', 'sm'), 'Open the session');
104
+ const b = button({ label: 'Open the session', onClick: () => ctx.openSession && ctx.openSession(ctx.id), attrs: { class: 'pill c3t-open', 'data-open-session': '', title: 'Open the session: its real terminal, on this page' } });
105
+ b.prepend(ic('term', 'sm'));
106
+ return b;
104
107
  }
105
108
 
106
109
  /* ── the next moves, the sub-chips, the tray, the voice line ── */
@@ -13,6 +13,8 @@ import { mountRunCard, disposeRunCard, openBtn, uiFor } from './run-card.js';
13
13
  import { source, needsYou } from './c3-terminal-data.js';
14
14
  import { attachExpand, makeXp } from './c3-expand.js';
15
15
  import { toast } from './c3-toast.js';
16
+ import { pmPost } from './http.js';
17
+ import { button } from './primitives.js';
16
18
 
17
19
  const live = new WeakMap();
18
20
  const DOT = { working: 'w', queued: 'w', asking: 'n', permission: 'n', attention: 'n', not_started: 'n', failed: 'b', quiet: '', paused: 'n', done: '' };
@@ -20,6 +22,10 @@ const DOT = { working: 'w', queued: 'w', asking: 'n', permission: 'n', attention
20
22
  const ago = (iso) => { const m = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)); return Number.isFinite(m) ? (m < 1 ? 'just now' : m < 60 ? m + ' min' : m < 1440 ? Math.round(m / 60) + ' h' : Math.round(m / 1440) + ' d') : ''; };
21
23
  const when = (iso) => { const d = new Date(iso); return Number.isFinite(+d) ? d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : ''; };
22
24
  const stateWord = (c) => (c.permissions && c.permissions.length ? 'waiting for your allow or deny' : ({ asking: 'asking you', permission: 'waiting for your allow or deny', working: 'working', queued: 'starting', quiet: 'quiet', failed: 'failed', done: 'result ready', attention: 'needs the terminal', not_started: 'not started', paused: 'paused', degraded: 'plain text only' })[c.state] || c.state);
25
+ /* A run with no reply lines (a plain terminal session) folds to 'queued', which read as "starting" for days. The server's own state of the
26
+ * session (GET /api/runs carries it as `label`) says what it is doing instead. */
27
+ const plain = (s, c) => (!c || (c.state === 'queued' && !(c.steps || []).length && !(c.permissions || []).length)) && !!s.label;
28
+ const liveWord = (s, c) => (plain(s, c) ? s.label : stateWord(c || { state: 'working' }));
23
29
  function outcomeOf(card, meta) {
24
30
  if (meta && meta.out) return meta.out;
25
31
  if (!card) return 'ended';
@@ -64,10 +70,37 @@ function blankPanel(st, s, card) {
64
70
  return el('div', { class: 'c3t-panel', 'data-blank-panel': s.id },
65
71
  can ? el('div', { class: 'feed' }, chat.map((m) => el('div', { class: 'm ' + m[0] }, m[1]))) : null,
66
72
  can ? el('div', { class: 'c3t-tray' }, ["Review this week's slips", 'Draft the monthly report'].map((t, i) => el('button', { type: 'button', class: 'pill' + (i ? '' : ' rec'), onclick: () => say(t) }, t))) : null,
67
- el('div', { class: 'c3t-tray' }, input, openBtn({ id: s.id, openSession: st.openSession })),
73
+ el('div', { class: 'c3t-tray' }, input, openBtn({ id: s.id, openSession: st.openSession }), endBtn(st, s)),
74
+ st.confirm === s.id ? endConfirm(st, s) : null,
68
75
  can ? null : dim(st.src.why));
69
76
  }
70
77
 
78
+ /* End: the route the dock's End uses, POST /api/session/remove (it ends the process and removes the session). Asked first, as the dock asks. */
79
+ /* a primitives.js button with the console3 pill look (as c3-swipe.js's confirm): the icon goes before the label */
80
+ function pill(label, icon, onClick, attrs) {
81
+ const b = button({ label, onClick, attrs: { ...attrs, class: 'pill' + (attrs && attrs.class ? ' ' + attrs.class : '') } });
82
+ if (icon) b.prepend(ic(icon, 'sm'));
83
+ return b;
84
+ }
85
+ function endBtn(st, s) {
86
+ return pill('End the session', 'stop', () => { st.confirm = st.confirm === s.id ? null : s.id; st.redraw(); },
87
+ { 'data-end-session': s.id, 'aria-expanded': String(st.confirm === s.id), title: 'End the session' });
88
+ }
89
+ function endConfirm(st, s) {
90
+ return el('div', { class: 'c3t-tray', role: 'group', 'aria-label': 'End this session?', 'data-end-confirm': s.id },
91
+ dim('End it? Any pending change stays a proposal.'),
92
+ pill('End', 'stop', () => endSession(st, s), { class: 'rec', 'data-end-yes': s.id }),
93
+ pill('Keep it', null, () => { st.confirm = null; st.redraw(); }, {}));
94
+ }
95
+ async function endSession(st, s) {
96
+ st.confirm = null;
97
+ if (st.src.demo) { toast('In the real workspace this ends the session.'); st.redraw(); return; }
98
+ const r = await pmPost('/api/session/remove', { id: s.id });
99
+ 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 });
100
+ else toast({ text: 'Not ended: ' + ((r && r.error) || 'the workspace server did not answer'), tone: 'error' });
101
+ st.last = ''; st.redraw(); st.refresh();
102
+ }
103
+
71
104
  function tlBody(st, s, card, meta) {
72
105
  const tl = st.data.demo && st.data.demo.timeline;
73
106
  const kids = [];
@@ -92,13 +125,14 @@ function draw(host, st) {
92
125
  /* On a card: each run is its card */
93
126
  const on = g.onCard || [];
94
127
  root.append(section('spark', 'On a card', on.length, null, on.length
95
- ? el('div', { class: 'c3t-cards', 'data-c3t': 'on-card' }, on.map((s) => cardHost(st, s, cards[s.id] || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] })))
128
+ ? el('div', { class: 'c3t-cards', 'data-c3t': 'on-card' }, on.map((s) => el('div', null, cardHost(st, s, cards[s.id] || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] }),
129
+ el('div', { class: 'c3t-tray' }, endBtn(st, s)), st.confirm === s.id ? endConfirm(st, s) : null)))
96
130
  : none('None running. A result appears on its card.')));
97
131
  /* Started by me */
98
132
  const mine = g.startedByMe || [];
99
133
  const add = el('a', { class: 'chip2 lk c3t-new', href: '/', title: 'Start a session in the main app. A session starts over the terminal connection; this page has no route for it.' }, ic('plus', 'sm'), 'New');
100
134
  root.append(section('term', 'Started by me', mine.length, add, mine.length
101
- ? el('ul', { class: 'list', 'data-c3t': 'mine' }, mine.map((s) => { const c = cards[s.id]; return row(st, s, c, { sub: stateWord(c || { state: 'working' }) + ' · ' + ago(s.startedAt) + ' · main tree', body: [cardHost(st, s, c || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] }), blankPanel(st, s, c)] }); }))
135
+ ? el('ul', { class: 'list', 'data-c3t': 'mine' }, mine.map((s) => { const c = cards[s.id]; return row(st, s, c, { sub: liveWord(s, c) + ' · ' + ago(s.startedAt) + ' · main tree', body: [plain(s, c) ? null : cardHost(st, s, c || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] }), blankPanel(st, s, c)].filter(Boolean) }); }))
102
136
  : none('None. Start one in the main app.')));
103
137
  /* Past */
104
138
  const past = g.past || [];
@@ -120,7 +154,9 @@ export function mountSessions(host, ctx = {}) {
120
154
  disposeSessions(host);
121
155
  if (ctx && ctx.FIXTURE) return;
122
156
  ensureC3Css();
123
- const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(), openSession: (id) => { try { window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id } })); } catch { /* no event */ } } };
157
+ const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(), confirm: null,
158
+ openSession: (id) => { const r = st.data && st.data.groups ? [].concat(...Object.values(st.data.groups).filter(Array.isArray)).find((x) => x.id === id) : null;
159
+ import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: r ? r.name : id })); } };
124
160
  if (st.src.demo) st.openSession = () => toast('In the real workspace this opens the CLI session.');
125
161
  st.redraw = () => draw(host, st);
126
162
  const busy = () => host.contains(document.activeElement) && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName);
@@ -136,6 +136,7 @@
136
136
  "pm-portfolio-inline-test.js",
137
137
  "pm-brief-page-test.js",
138
138
  "c3-settings-test.js",
139
+ "c3-sessions-panel-test.js",
139
140
  "c3-updates-test.js"
140
141
  ],
141
142
  "ui": [
@@ -269,6 +270,7 @@
269
270
  "pm-portfolio-inline-test.js": 15,
270
271
  "settings-store.test.js": 1,
271
272
  "c3-settings-test.js": 25,
273
+ "c3-sessions-panel-test.js": 20,
272
274
  "c3-updates-test.js": 20
273
275
  },
274
276
  "_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."