create-pmos 0.503.0 → 0.505.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.503.0",
3
+ "version": "0.505.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": {
@@ -32,6 +32,32 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.505.0",
37
+ "at": "2026-11-03T10:44:31.649Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1190,
42
+ "t": "Finish a recording from any tab that shows it",
43
+ "s": "Finish a recording from any tab that shows it",
44
+ "k": "fix"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "v": "0.504.0",
50
+ "at": "2026-11-03T05:59:07.712Z",
51
+ "f": 3,
52
+ "prs": [
53
+ {
54
+ "n": 1189,
55
+ "t": "The Working card ends when Claude's answer ends",
56
+ "s": "The Working card ends when Claude's answer ends",
57
+ "k": "fix"
58
+ }
59
+ ]
60
+ },
35
61
  {
36
62
  "v": "0.503.0",
37
63
  "at": "2026-11-03T05:42:41.752Z",
@@ -409,32 +435,6 @@
409
435
  "k": "new"
410
436
  }
411
437
  ]
412
- },
413
- {
414
- "v": "0.485.0",
415
- "at": "2026-11-02T02:54:02.793Z",
416
- "f": 3,
417
- "prs": [
418
- {
419
- "n": 1145,
420
- "t": "Task dates read through pmos (item 19); a report draft says why it ended (item 20)",
421
- "s": "Task dates read through pmos (item 19); a report draft says why it ended (item 20)",
422
- "k": "fix"
423
- }
424
- ]
425
- },
426
- {
427
- "v": "0.484.0",
428
- "at": "2026-11-02T02:00:11.221Z",
429
- "f": 3,
430
- "prs": [
431
- {
432
- "n": 1143,
433
- "t": "Program chats get the header pill; a phone shows every PDF page (batch 22 items 17, 18)",
434
- "s": "Program chats get the header pill; a phone shows every PDF page (batch 22 items 17, 18)",
435
- "k": "fix"
436
- }
437
- ]
438
438
  }
439
439
  ]
440
440
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@8b7c5275
1
+ kernel-source: pmos@dd7330f0
2
2
  assembled: 2026-11-03
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-11-03T05:42:38Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
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-11-03T05:42:38Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
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-11-03T05:42:38Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "v": "0.505.0",
4
+ "at": "2026-11-03T10:44:31.649Z",
5
+ "f": 3,
6
+ "prs": [
7
+ {
8
+ "n": 1190,
9
+ "t": "Finish a recording from any tab that shows it",
10
+ "s": "Finish a recording from any tab that shows it",
11
+ "k": "fix"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "v": "0.504.0",
17
+ "at": "2026-11-03T05:59:07.712Z",
18
+ "f": 3,
19
+ "prs": [
20
+ {
21
+ "n": 1189,
22
+ "t": "The Working card ends when Claude's answer ends",
23
+ "s": "The Working card ends when Claude's answer ends",
24
+ "k": "fix"
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "v": "0.503.0",
4
30
  "at": "2026-11-03T05:42:41.752Z",
@@ -66,7 +66,14 @@ The recording tab holds the recording and stays on the meeting. When you open Ho
66
66
  - **Not hearing the meeting**: an amber line with **Share again** shows when the microphone is heard and the meeting is not. The snippet on other pages says it too.
67
67
  - **The next meeting**: if you press **Record** on another meeting while one records, the recording tab asks once: **Stop Viewer sync and record Design review?**
68
68
 
69
- Only the recording tab controls the meeting. Every other page shows a snippet: the first row of the Live pill, the pinned row on Home, and the live meeting in the Viewer. A snippet shows the dot, the clock and the title. Press it to bring the recording tab forward. A snippet has no **Pause**, **Stop** or **Highlight**.
69
+ Every other page shows a snippet: the first row of the Live pill, the pinned row on Home, and the live meeting in the Viewer. A snippet shows the dot, the clock and the title. Press it to bring the recording tab forward. **Highlight** and **Pause** are only in the recording tab.
70
+
71
+ You can finish the meeting from any page that shows it:
72
+
73
+ - The Live pill's row and Home's pinned row have a red **Stop** icon beside them. Press it once and it turns solid red with **Press again to stop the recording**. Press it again within 4 seconds to stop. If you do not press again, it goes back.
74
+ - A meeting page that does not hold the recording shows **Recording in another tab.** with the red **Stop**. This happens in a second tab or after you reload the recording tab. **Stop** asks first.
75
+
76
+ When you stop from another page, the recording tab stops listening at once and shows the meeting's notes.
70
77
 
71
78
  If you close the recording tab, the browser asks first. If you leave, the meeting pauses. Press the snippet to open the tab again, then press **Resume**. On a phone, the recording view opens in the same tab.
72
79
 
@@ -236,9 +236,20 @@ function newHarnessAcc() {
236
236
  // more. `ai-title` and `attachment` records are CLI bookkeeping and are deliberately not
237
237
  // turn-taking, and neither is a SYNTHETIC assistant record: counting one as "the assistant
238
238
  // finished speaking" is exactly how a false `needs you` would be manufactured.
239
- lastRole: null }; // 'assistant' | 'user' | null
239
+ lastRole: null, // 'assistant' | 'user' | null
240
+ msgId: null, // the newest real model message's id: a new one closes the older messages' calls (feedHarness)
241
+ cmdFrom: undefined }; // the turn before a /command record, given back when the CLI answers the command itself
240
242
  }
241
243
 
244
+ // The text of a user record, for the turn rules below: string content, or its text blocks joined.
245
+ function userRecordText(um) {
246
+ if (typeof um.content === 'string') return um.content;
247
+ return (Array.isArray(um.content) ? um.content : []).filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n');
248
+ }
249
+ const INTERRUPTED = /^\s*\[Request interrupted by user/;
250
+ const LOCAL_OUT = /^\s*<(local-command-stdout|local-command-stderr)>/;
251
+ const LOCAL_IN = /^\s*<(bash-input|bash-stdout|bash-stderr)>|^\s*Caveat: The messages below/;
252
+
242
253
  function feedHarness(acc, raw, clean) {
243
254
  for (const line of raw.split('\n')) {
244
255
  if (!line) continue;
@@ -253,13 +264,27 @@ function feedHarness(acc, raw, clean) {
253
264
  }
254
265
  // tool_result blocks live inside `user` records; harvest the ids they resolve.
255
266
  if (r.type === 'user') {
267
+ if (r.isSidechain) continue; // a subagent's own records: its calls and results never hold the main session's turn
256
268
  const um = r.message || {};
269
+ let result = false;
257
270
  for (const c of (Array.isArray(um.content) ? um.content : [])) {
258
271
  if (c && c.type === 'tool_result' && c.tool_use_id) {
272
+ result = true;
259
273
  if (!acc.openUse.delete(c.tool_use_id)) acc.resolved.add(c.tool_use_id);
260
274
  }
261
275
  }
262
- acc.lastRole = 'user'; // a prompt was submitted, or a tool result landed
276
+ const said = result ? '' : userRecordText(um);
277
+ // batch 25 item 15: only a prompt or a tool result gives the model the turn. The CLI's own records after a finished
278
+ // answer (a meta note, a compact summary, a local command such as /model and its output, a `!` shell line) start no
279
+ // model turn, so counting them left the chat on "Working" for hours after Claude had answered.
280
+ if (result) acc.lastRole = 'user';
281
+ else if (INTERRUPTED.test(said)) { acc.lastRole = 'assistant'; acc.openUse.clear(); } // Esc / Stop: the model stopped, the PM is up
282
+ else if (LOCAL_OUT.test(said)) { if (acc.cmdFrom !== undefined) acc.lastRole = acc.cmdFrom; acc.cmdFrom = undefined; } // a local command answered itself
283
+ else if (r.isMeta || r.isCompactSummary || r.isVisibleInTranscriptOnly || LOCAL_IN.test(said)) { /* bookkeeping: not turn-taking */ }
284
+ else {
285
+ if (/<command-name>/.test(said)) acc.cmdFrom = acc.lastRole; // /name: a prompt command takes the turn; a local one gives it back above
286
+ acc.lastRole = 'user'; // a prompt was submitted
287
+ }
263
288
  continue;
264
289
  }
265
290
  if (r.type === 'attachment') {
@@ -274,6 +299,13 @@ function feedHarness(acc, raw, clean) {
274
299
  }
275
300
  if (r.type !== 'assistant') continue;
276
301
  const m = r.message || {};
302
+ if (m.model && m.model !== SYNTHETIC && !r.isSidechain && m.id && m.id !== acc.msgId) {
303
+ // A new model message starts only once every call of the one before it has its result: the model cannot go on without
304
+ // them. So a call of an older message that is still unresolved is a phantom (a crash, a killed pane, a resumed chat), never
305
+ // a call in flight, and it must not hold the turn (batch 25 item 15: a chat showed "Working · 169:28" under its answer).
306
+ for (const [id, info] of acc.openUse) if (info.msg !== m.id) acc.openUse.delete(id);
307
+ acc.msgId = m.id;
308
+ }
277
309
  if (m.model && m.model !== SYNTHETIC) {
278
310
  acc.model = clean(m.model);
279
311
  acc.turns++; // only real model turns count
@@ -298,7 +330,7 @@ function feedHarness(acc, raw, clean) {
298
330
  const name = clean(prettyTool(c.name));
299
331
  acc.toolCounts[name] = (acc.toolCounts[name] || 0) + 1;
300
332
  acc.toolTotal++;
301
- if (c.id && !acc.resolved.delete(c.id)) acc.openUse.set(c.id, { verb: name, ts: r.timestamp || null });
333
+ if (c.id && !r.isSidechain && !acc.resolved.delete(c.id)) acc.openUse.set(c.id, { verb: name, ts: r.timestamp || null, msg: m.id || null });
302
334
  }
303
335
  }
304
336
  }
@@ -320,6 +352,9 @@ function harnessOf(acc) {
320
352
  // the strong `needs you` claim can be built) is UNREACHABLE for it. Not a guard that rejects
321
353
  // the phantom: a value it cannot construct. Reordering these branches silently converts a
322
354
  // corpse into "waiting on you", which is the one lie this surface must not tell.
355
+ // A phantom stays only while nothing came after it: once a NEWER model message exists, the session demonstrably went
356
+ // on past that call, so feedHarness drops it (batch 25 item 15). A crash with the call as its newest record still lands
357
+ // on `tool`, exactly as above.
323
358
  if (out.openCall) out.turn = 'tool'; // awaiting a tool — OR the PM's approval of one
324
359
  else if (acc.lastRole === 'assistant') out.turn = 'user'; // the model stopped; the human is up
325
360
  else if (acc.lastRole === 'user') out.turn = 'agent'; // a prompt/result landed; the model is up
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.503.0",
3
+ "version": "0.505.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.503.0",
9
+ "version": "0.505.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.503.0",
3
+ "version": "0.505.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",
@@ -23,6 +23,14 @@
23
23
  .hr-rec-card:not(.paused) .hr-rdot::after { content: ""; position: absolute; inset: -1.5px; border-radius: inherit; background: var(--cz-dot-red); animation: hr-crit 2.4s ease-out infinite; pointer-events: none; }
24
24
  @keyframes hr-crit { 0% { transform: scale(1); opacity: .4; } 60% { transform: scale(2.2); opacity: 0; } 100% { transform: scale(2.2); opacity: 0; } }
25
25
  @media (prefers-reduced-motion: reduce) { .hr-rec-card .hr-rdot::after { animation: none; } }
26
+ /* the icon-only Stop beside the pinned row (finish from anywhere, 2026-11-03): quiet red square, solid red once pressed ("Press again") */
27
+ .hr-rec { display: flex; align-items: center; }
28
+ .hr-stop { flex: none; width: 40px; height: 40px; margin-right: 10px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
29
+ .hr-stop svg { width: 16px; height: 16px; fill: currentColor; }
30
+ .hr-stop:hover { background: var(--cz-well); }
31
+ .hr-stop.armed { background: var(--cz-dot-red); color: var(--cz-onsolid); }
32
+ .hr-stop:disabled { opacity: .5; cursor: default; }
33
+ .hr-stop:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
26
34
 
27
35
  /* ── the Mentions Viewer: the round solid press and the 30-day question (kept from Mentions A+) ── */
28
36
  .hr-rp { width: 44px; height: 44px; border-radius: 999px; border: 0; background: var(--cz-solid); color: var(--cz-onsolid); display: inline-grid; place-items: center; cursor: pointer; flex: none; }
@@ -35,6 +35,16 @@
35
35
  .lv-row + .lv-row::before { content: ''; position: absolute; left: 38px; right: 12px; top: 0; height: 1px; background: var(--cz-hair); }
36
36
  .lv-row:hover { background: var(--cz-well); }
37
37
  .lv-row.cur { background: var(--cz-bluebg); }
38
+ /* the recording's row and its icon-only Stop (finish from anywhere, 2026-11-03): quiet until pressed once, then solid red ("Press again") */
39
+ .lv-rw { position: relative; display: flex; align-items: center; }
40
+ .lv-rw > .lv-row { flex: 1; min-width: 0; }
41
+ .lv-rw + .lv-row::before, .lv-row + .lv-rw::before { content: ''; position: absolute; left: 38px; right: 12px; top: 0; height: 1px; background: var(--cz-hair); }
42
+ .lv-stop { flex: none; width: 36px; height: 36px; margin-right: 4px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
43
+ .lv-stop svg { width: 16px; height: 16px; fill: currentColor; }
44
+ .lv-stop:hover { background: var(--cz-well); }
45
+ .lv-stop.armed { background: var(--cz-dot-red); color: var(--cz-onsolid); }
46
+ .lv-stop:disabled { opacity: .5; cursor: default; }
47
+ .lv-stop:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
38
48
  .lv-mk { width: 24px; flex: none; display: inline-flex; justify-content: center; align-items: center; }
39
49
  .lv-t { flex: 1; min-width: 0; font-size: var(--fs-3); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
40
50
  .lv-t small { display: block; font-size: var(--fs-2); font-weight: 600; color: var(--cz-ink3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -16,10 +16,11 @@
16
16
  * opts.go(item) → true the page opens the item itself (Home: the session's chat, the meeting); else the row's link is followed
17
17
  * opts.open() → id the session open on this page now: its row is tinted
18
18
  * Reads GET /api/live every 10 s while the page is shown, and on the cz:live event (Home sends it after the PM opens or answers a
19
- * session); the recording comes from pm/rec-state.js. Nothing is written.
19
+ * session); the recording comes from pm/rec-state.js. The recording's row has one icon-only Stop beside it (Wawan, 2026-11-03: finish
20
+ * from anywhere): the first press asks "Press again", the second ends the meeting (rec-state.js stopPress). Nothing else is written.
20
21
  * Adler (batch 17 item 4-B, AdM1 "Live recording pill", AdH5): while a meeting records, his S face with headphones follows the clock, now in
21
22
  * the recording's row. The red dot stays: the face never replaces the row's mark. Paused, he is not shown (nobody is listening). */
22
- import { onRecording } from './rec-state.js';
23
+ import { onRecording, stopPress } from './rec-state.js';
23
24
  import { ownTab, bring } from './rec-tab-core.js';
24
25
  import { liveItems, keepOrder, pillLabel, rowLabel, clock, elapsed } from './live-model.js';
25
26
  import { wedgeHtml } from './ctx-wedge.js';
@@ -27,6 +28,7 @@ import { adler } from './cozy-adler.js'; // the same context pie as Home's row
27
28
 
28
29
  const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
29
30
  const SHEET = new URL('./cozy-live-pill.css', import.meta.url).href;
31
+ const STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
30
32
  const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
31
33
  const PHONE = '(max-width: 760px)';
32
34
  const mac = () => { try { return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ''); } catch { return false; } };
@@ -49,7 +51,11 @@ export function mountLivePill(doc = document, opts = {}) {
49
51
  nav.append(scrim, tray, pill);
50
52
  pill.addEventListener('click', () => set(!L.open));
51
53
  scrim.addEventListener('click', () => set(false));
52
- tray.addEventListener('click', (e) => { const a = e.target.closest('a[data-lv]'); if (a) go(a, e); });
54
+ tray.addEventListener('click', (e) => {
55
+ const s = e.target.closest('[data-lv-stop]');
56
+ if (s) { e.preventDefault(); const p = stopPress(s, s.dataset.lvStop); if (p) p.then((r) => { if (r && r.ok) set(false); }); return; }
57
+ const a = e.target.closest('a[data-lv]'); if (a) go(a, e);
58
+ });
53
59
  tray.addEventListener('keydown', keys);
54
60
  draw();
55
61
  return true;
@@ -77,11 +83,13 @@ export function mountLivePill(doc = document, opts = {}) {
77
83
  function rowsHtml(list) {
78
84
  return list.map((x, i) => {
79
85
  const was = L.marks.get(x.id), once = L.open && was && was !== x.mark;
80
- return `<a class="lv-row${x.open ? ' cur' : ''}" href="${esc(x.href)}" data-lv="${esc(x.id)}" aria-label="${esc(rowLabel(x))}" aria-keyshortcuts="${i + 1}">`
86
+ const row = `<a class="lv-row${x.open ? ' cur' : ''}" href="${esc(x.href)}" data-lv="${esc(x.id)}" aria-label="${esc(rowLabel(x))}" aria-keyshortcuts="${i + 1}">`
81
87
  + `<span class="lv-mk">${x.kind === 'rec' ? `<i class="lv-m rec${x.paused ? ' paused' : ''}"></i>` : markHtml(x.mark, once)}</span>`
82
88
  + `<span class="lv-t">${esc(x.name)}<small>${esc(x.sub)}</small></span>${wedgeHtml(x.ctx)}`
83
89
  + (x.kind === 'rec' && !x.paused ? `<span class="lv-ad">${adler('thinking', 18, { label: false, prop: 'headphones', id: 'lvrec' })}</span>` : '')
84
90
  + `<span class="lv-tm${x.kind === 'rec' && !x.paused ? ' rec' : x.mark === 'stop' ? ' stop' : ''}">${esc(x.time)}</span></a>`;
91
+ // the recording: one icon-only Stop beside its row, so the meeting can be finished from any page
92
+ return x.kind === 'rec' ? `<div class="lv-rw">${row}<button type="button" class="lv-stop" data-lv-stop="${esc(x.id.slice(4))}" aria-label="Stop the recording" title="Stop the recording">${STOP}</button></div>` : row;
85
93
  }).join('');
86
94
  }
87
95
  // the closed pill: the number of live items, and a red corner dot while a meeting records (grey while paused); no marks, no clock
@@ -103,7 +111,7 @@ export function mountLivePill(doc = document, opts = {}) {
103
111
  pill.setAttribute('aria-label', pillLabel(now, { mac: mac() }));
104
112
  pill.title = tipOf(now);
105
113
  const h = pillHtml(now); if (pill.__h !== h) { pill.__h = h; pill.innerHTML = h; }
106
- if (L.open) {
114
+ if (L.open && !tray.querySelector('.lv-stop.armed, .lv-stop:disabled')) { // a Stop waiting for its second press keeps the list as it is
107
115
  const f = doc.activeElement && tray.contains(doc.activeElement) ? doc.activeElement.dataset.lv : null;
108
116
  tray.innerHTML = rowsHtml(list);
109
117
  if (f) { const a = [...tray.querySelectorAll('a[data-lv]')].find((n) => n.dataset.lv === f); if (a) a.focus(); }
@@ -15,6 +15,8 @@
15
15
  * alert amber "Not hearing the meeting" with Share again, when the microphone is heard and the meeting is not.
16
16
  * ask Stop asks first. Record on the next meeting, pressed on another page, asks here once: "Stop X and record Y?".
17
17
  * Stop ends the meeting, and the tab then shows its notes (the Viewer, /premium-viewer.html?open=meeting).
18
+ * Finish from anywhere (Wawan, 2026-11-03): a tab that shows the meeting but does not hold the recorder ("Recording in another tab.", or the
19
+ * tab reloaded) still has the red Stop. The server ends the meeting, and the tab that holds the recorder hears 'ended' and goes to the notes.
18
20
  *
19
21
  * Slots: S2 and S3 load here if their files exist (a missing file is ignored):
20
22
  * cozy-rec-now.js mountNow({ stage, side, bar }, ctx) ctx = { ws, note(), mode(), on(evt, fn), focus(), event, rec }
@@ -118,7 +120,7 @@ function drawStage() {
118
120
  const left = S.row && S.row.resume_until ? Math.max(1, Math.ceil((Date.parse(S.row.resume_until) - Date.now()) / 60000)) : null;
119
121
  own.innerHTML = `<div class="rt-wait"><h2>Paused</h2><p>Nothing is kept until you resume${left ? '. ' + left + (left === 1 ? ' minute' : ' minutes') + ' left to resume' : ''}.</p></div>`; return;
120
122
  }
121
- if (S.mode === 'idle') { own.innerHTML = `<div class="rt-wait"><h2>${esc(title())}</h2><p>${esc(S.msg || (S.row && S.row.state === 'recording' ? 'Recording in another tab.' : 'Not recording yet.'))}</p>${S.row && S.row.state !== 'recording' ? btn('start', 'Start recording', 'rec', 'red big') : ''}</div>`; return; }
123
+ if (S.mode === 'idle') { own.innerHTML = `<div class="rt-wait"><h2>${esc(title())}</h2><p>${esc(S.msg || (S.row && S.row.state === 'recording' ? 'Recording in another tab.' : 'Not recording yet.'))}</p>${S.row && S.row.state !== 'recording' ? btn('start', 'Start recording', 'rec', 'red big') : S.row ? btn('stop', 'Stop the recording', 'stop', 'red big') : ''}</div>`; return; }
122
124
  own.innerHTML = '<div class="rt-wait"><h2>Stopped</h2><p>The notes are being written.</p></div>';
123
125
  }
124
126
  function drawLine() {
@@ -198,7 +200,7 @@ async function act(a, t) {
198
200
  case 'shots': { C.shots(!C.screen().paused); return draw(); }
199
201
  case 'stop': S.ask = { kind: 'stop' }; return drawAsk();
200
202
  case 'askno': S.ask = null; drawAsk(); return draw();
201
- case 'stopgo': S.ask = null; drawAsk(); return run(async () => { const r = await C.stop(ID); S.msg = r.ok ? '' : r.error; });
203
+ case 'stopgo': S.ask = null; drawAsk(); return run(async () => { const r = await C.stop(ID); S.msg = r.ok ? '' : r.error; if (r.ok) post({ id: ID, state: 'ended', title: title(), elapsedMs: elapsed(), silent: false }); });
202
204
  case 'switchgo': {
203
205
  const to = S.ask && S.ask.body; S.ask = null; drawAsk();
204
206
  return run(async () => {
@@ -215,6 +217,7 @@ document.addEventListener('click', (e) => { const t = e.target.closest('[data-a]
215
217
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && S.ask) { S.ask = null; drawAsk(); } });
216
218
 
217
219
  /* Record on the next meeting, pressed on another page while this one records: ask here once (rec-tab-core.js asks over the channel) */
220
+ listen((d) => { if (d.id === ID && d.state === 'ended' && !READING) { READING = true; read().catch(() => {}).finally(() => { READING = false; }); } }); // finished on another page
218
221
  listen((d) => { if (d.id === ID && d.switchTo && d.switchTo.body) { S.ask = { kind: 'switch', title: clip(d.switchTo.title || 'the next meeting', 80), body: d.switchTo.body }; drawAsk(); } });
219
222
 
220
223
  /* ── the slots: S2 (Now panel) and S3 (Present) mount here when their files are on the page ─────────────────── */
@@ -3,6 +3,8 @@
3
3
  * While a meeting records, a row sits above @Mentions: a red outline, a red dot, the meeting's title and "Recording · 12:40", and the
4
4
  * clock ticks. It stays while the PM opens other items. A press opens the live meeting (data-hm-recopen, which home-app.js already opens
5
5
  * in place). Paused: "Paused · 12:40", quieter, and the clock stands still. With no recording there is no row.
6
+ * Beside it, one icon-only Stop (Wawan, 2026-11-03: finish from anywhere): the first press asks "Press again", the second ends the meeting
7
+ * (rec-state.js stopPress), wherever the recorder runs.
6
8
  *
7
9
  * mountRecRow({ notes, refresh, redraw }) → { html() }
8
10
  * notes() the notes Home already reads (premium-meeting.js recCore().notes()), the fallback while rec-state.js is not on the page
@@ -10,7 +12,7 @@
10
12
  * redraw() Home draws its list again: called when the recording starts, pauses, resumes, stops or changes its title
11
13
  *
12
14
  * The state comes from rec-state.js (onRecording, the meetings workstream) when the page has it, else from the notes, read by the same rule
13
- * (a note recording or paused; meeting_ms is the meeting time heard). Nothing is written here. */
15
+ * (a note recording or paused; meeting_ms is the meeting time heard). Only the Stop writes (POST /api/notes/stop, through rec-state.js). */
14
16
  import { sheet } from './home-mentions.js';
15
17
 
16
18
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -36,7 +38,8 @@ export function recRowHtml(st, now = Date.now()) {
36
38
  return `<div class="hv-card hr-card hr-rec-card${st.paused ? ' paused' : ''}"><div class="hm-r hv-r hr-rec" data-id="rec:${esc(st.id)}">`
37
39
  + `<button type="button" class="hm-rm" data-hm-recopen="${esc(st.id)}" aria-label="${esc(st.title)}. ${word}. Open the meeting">`
38
40
  + `<span class="hv-mk"><span class="hr-rdot" aria-hidden="true"></span></span><span class="hm-rt"><b>${esc(st.title)}</b></span>`
39
- + `<span class="hv-st hr-rst">${word} · <span data-hr-clock>${clockOf(elapsed(st, now))}</span></span><span class="hv-xc" aria-hidden="true"></span></button></div></div>`;
41
+ + `<span class="hv-st hr-rst">${word} · <span data-hr-clock>${clockOf(elapsed(st, now))}</span></span><span class="hv-xc" aria-hidden="true"></span></button>`
42
+ + `<button type="button" class="hr-stop" data-hr-stop="${esc(st.id)}" aria-label="Stop the recording" title="Stop the recording"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg></button></div></div>`;
40
43
  }
41
44
 
42
45
  export function mountRecRow({ notes, refresh, redraw } = {}) {
@@ -50,6 +53,12 @@ export function mountRecRow({ notes, refresh, redraw } = {}) {
50
53
  return st;
51
54
  };
52
55
  const state = () => live || fromNotes();
56
+ document.addEventListener('click', (e) => {
57
+ const b = e.target && e.target.closest ? e.target.closest('[data-hr-stop]') : null;
58
+ if (!b) return;
59
+ e.preventDefault(); e.stopPropagation();
60
+ import('./rec-state.js').then((M) => { const p = M.stopPress(b, b.dataset.hrStop); if (p) p.then((r) => { if (r && r.ok && refresh) Promise.resolve(refresh()).then(() => redraw && redraw()); }); }).catch(() => {});
61
+ }, true);
53
62
  const changed = (st) => { const k = st.on ? [st.id, st.paused, st.title].join('|') : ''; if (k === seen) return false; seen = k; return true; };
54
63
  import('./rec-state.js').then((M) => {
55
64
  M.onRecording((st) => { live = st && typeof st === 'object' ? st : null; if (changed(state()) && redraw) redraw(); });
@@ -4,6 +4,10 @@
4
4
  * recState({ fresh }) → Promise<State> the recording now, read from GET /api/notes (kept 5 s unless fresh)
5
5
  * onRecording(fn) → unsubscribe fn(State) now and on each change; while someone listens the page reads again every 10 s
6
6
  * announce() a page that just started, paused, resumed or stopped a recording asks for a fresh read
7
+ * finish(id) → { ok, error } end the meeting from any page (Wawan, 2026-11-03: "finish recording from anywhere").
8
+ * The server ends it; the tab that holds the recorder hears 'ended' and closes the mic.
9
+ * stopPress(el, id) → Promise|undefined the icon-only Stop on a snippet: the first press arms it (it asks "Press again to
10
+ * stop"), a second press within 4 s finishes the meeting
7
11
  * EVENT = 'cz:recording' the same State, as a CustomEvent on document, on each change
8
12
  * window.czRecording = { state: recState, on: onRecording }
9
13
  *
@@ -16,10 +20,10 @@
16
20
  * here this page holds the recorder (premium-meeting.js sets window.__premiumHolding), or is the recording tab
17
21
  * silent the recording tab hears the microphone but not the meeting ("Not hearing the meeting")
18
22
  * The recording tab also sends its state over a BroadcastChannel (rec-tab-core.js post) on each change and every 5 s: a page reads again
19
- * at once, so its snippet never runs a stale clock. The channel carries state only.
23
+ * at once, so its snippet never runs a stale clock. The channel carries state, and 'ended' when another page finished the meeting.
20
24
  * The answer comes from the notes the server keeps, so a page that did not start the recording still knows of it. Nothing is written. */
21
- import { pmGet } from './http.js';
22
- import { tabUrl, isTab, listen } from './rec-tab-core.js';
25
+ import { pmGet, pmPost } from './http.js';
26
+ import { tabUrl, isTab, listen, post } from './rec-tab-core.js';
23
27
 
24
28
  export const EVENT = 'cz:recording';
25
29
  const OFF = Object.freeze({ on: false, paused: false, id: '', title: '', startedAt: 0, elapsedMs: 0, readAt: 0, href: '', here: false, silent: false });
@@ -66,6 +70,28 @@ export function onRecording(fn) {
66
70
  /** A recording was started, paused, resumed or stopped on this page: read again now. */
67
71
  export function announce() { return recState({ fresh: true }); }
68
72
 
73
+ /** End the meeting from this page, whichever tab holds the recorder. */
74
+ export async function finish(id) {
75
+ if (!id) return { ok: false, error: 'No meeting is recording.' };
76
+ const r = await pmPost('/api/notes/stop', { id: String(id) });
77
+ const ok = !!(r && r.ok === true);
78
+ if (ok) post({ id: String(id), state: 'ended', title: '', elapsedMs: 0, silent: false }); // the recording tab stops listening at once
79
+ await announce();
80
+ return ok ? { ok: true } : { ok: false, error: (r && r.error) || 'The meeting was not stopped.' };
81
+ }
82
+ const ARM_MS = 4000;
83
+ export function stopPress(el, id) {
84
+ if (!el || el.disabled) return undefined;
85
+ const say = (t) => { el.setAttribute('aria-label', t); el.title = t; };
86
+ if (el.dataset.armed !== '1') {
87
+ el.dataset.armed = '1'; el.classList.add('armed'); say('Press again to stop the recording');
88
+ clearTimeout(el.__arm); el.__arm = setTimeout(() => { delete el.dataset.armed; el.classList.remove('armed'); say('Stop the recording'); }, ARM_MS);
89
+ return undefined;
90
+ }
91
+ clearTimeout(el.__arm); el.disabled = true;
92
+ return finish(id).then((r) => { if (!r.ok) { el.disabled = false; delete el.dataset.armed; el.classList.remove('armed'); say(r.error); } return r; });
93
+ }
94
+
69
95
  if (typeof window !== 'undefined' && !isTab()) listen((d) => { heard = { id: d.id, silent: !!d.silent }; recState({ fresh: true }); });
70
96
 
71
- if (typeof window !== 'undefined') window.czRecording = { state: recState, on: onRecording, EVENT };
97
+ if (typeof window !== 'undefined') window.czRecording = { state: recState, on: onRecording, finish, EVENT };