create-pmos 0.521.0 → 0.522.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.
Files changed (33) hide show
  1. package/bin/create-pmos.js +81 -42
  2. package/package.json +26 -16
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/bin/pmos.js +2 -1
  8. package/workspace/board-publish-ui-test.js +1 -1
  9. package/workspace/changelog.json +25 -0
  10. package/workspace/guide/home.md +2 -2
  11. package/workspace/guide/published-board.md +15 -3
  12. package/workspace/lib/board-pack.js +44 -1
  13. package/workspace/lib/board-publish-choices.js +22 -6
  14. package/workspace/lib/board-publish-parts.js +95 -0
  15. package/workspace/lib/boards-host.js +3 -3
  16. package/workspace/lib/boards-received.js +105 -0
  17. package/workspace/lib/kit-update.js +51 -21
  18. package/workspace/lib/routes-board-publish.js +42 -12
  19. package/workspace/package-lock.json +2 -2
  20. package/workspace/package.json +1 -1
  21. package/workspace/public/pm/bd-publish.css +3 -0
  22. package/workspace/public/pm/bd-publish.js +39 -9
  23. package/workspace/public/pm/bd-shared.css +5 -0
  24. package/workspace/public/pm/bd-shared.js +72 -10
  25. package/workspace/public/pm/chat-term.js +22 -0
  26. package/workspace/public/pm/cozy-board.css +10 -0
  27. package/workspace/public/pm/cozy-home.css +3 -0
  28. package/workspace/public/pm/home-chat.js +3 -0
  29. package/workspace/public/pm/premium-board.js +8 -2
  30. package/workspace/public/pm/premium-shell.js +2 -1
  31. package/workspace/public/pm/readonly-boot.js +4 -1
  32. package/workspace/public/pm/readonly.js +40 -5
  33. package/workspace/public/pm/term-view.js +39 -8
@@ -53,7 +53,7 @@ export function css() {
53
53
  }
54
54
 
55
55
  /** The number of open copies: yours, plus each readable team's, without counting your own twice. */
56
- export function totalOf(S) { return rowsOf(S).filter((r) => !r.ended).length; }
56
+ export function totalOf(S) { return rowsOf(S).filter((r) => !r.ended).length + (S.withYou || []).filter((r) => r.state === 'open').length; }
57
57
 
58
58
  function rowsOf(S, now = S.now || Date.now()) {
59
59
  const mine = new Set((S.yours || []).map((l) => l.id));
@@ -113,34 +113,70 @@ function firstRun(S) {
113
113
  + `<div class="bdsh-form">${joinForm(J)}</div><p class="bdsh-meta bdsh-fine">${svg(I.lock, 14)} Kept on this computer only.</p></div>`;
114
114
  }
115
115
 
116
+ /** The paste field (batch 27 item 3, ShX X2): a board someone shared with you is kept here. Its password is asked once, when the board needs one. */
117
+ function pasteHtml(S) {
118
+ const A = S.add || {};
119
+ const go = ib('plus', A.busy ? 'Adding…' : 'Add this board', `data-sh="add"${A.busy ? ' disabled' : ''}`, 's pri');
120
+ return `<div class="bdsh-paste"><div class="bdsh-fld${A.err ? ' bad' : ''}">${svg(I.link, 18)}${field('shLink', 'Paste a shared board link', A.link)}${A.needPw ? '' : go}</div>`
121
+ + (A.needPw ? `<div class="bdsh-fld">${svg(I.lock, 18)}${field('shLinkPw', 'The board\'s password', '', 'password')}${go}</div>` : '')
122
+ + (A.err ? `<p class="bdsh-err" role="alert"><i class="cz-dot red"></i>${esc(A.err)}</p>` : '') + '</div>';
123
+ }
124
+
125
+ const RSTATE = { ended: ['Ended', ''], off: ['Off', ''], password: ['Password', 'w'], locked: ['Locked', 'w'], unreachable: ['Not read', ''] };
126
+ /** One board shared with you (ShX X2, X3). Healthy: its name opens it, the meta says who and when it ends, nothing more (zero is silence).
127
+ * Ended, turned off or a new password: one quiet line with the reason and an ✕ that forgets it. */
128
+ function recvCard(S, r) {
129
+ const now = S.now || Date.now(); const left = r.ends ? Date.parse(r.ends) - now : NaN;
130
+ const soon = r.state === 'open' && left > 0 && left < 7 * 86400000;
131
+ const days = Math.max(1, Math.ceil(left / 86400000));
132
+ const st = RSTATE[r.state];
133
+ const nm = r.state === 'open' ? `<button type="button" class="bdsh-nm bd-dlink" data-sh-visit="${esc(r.id)}">${esc(r.name || 'Shared board')}</button>`
134
+ : r.state === 'password' ? `<button type="button" class="bdsh-nm bd-dlink" data-sh-repw="${esc(r.id)}" title="Type the new password">${esc(r.name || 'Shared board')}</button>`
135
+ : `<span class="bdsh-nm bdsh-plain">${esc(r.name || 'Shared board')}</span>`;
136
+ const why = r.state === 'ended' ? 'ended ' + day(r.ends) : r.state === 'off' ? 'turned off' + (r.by ? ' by ' + r.by : '') : r.state === 'password' ? 'Needs a new password' : r.error || '';
137
+ const meta = `<span class="bdsh-meta">${[r.state === 'open' || r.state === 'ended' || r.state === 'off' ? r.by : '', r.state === 'open' && r.ends ? 'ends ' + day(r.ends) : why].filter(Boolean).map(esc).join(' · ')}</span>`;
138
+ const pill = st ? `<span class="bdsh-pill${st[1] ? ' ' + st[1] : ''}">${esc(st[0])}</span>` : soon ? `<span class="bdsh-pill w">Ends in ${days} ${days === 1 ? 'day' : 'days'}</span>` : '';
139
+ const act = r.state === 'open' ? ib('open', 'Open in a new tab', `data-sh-visit="${esc(r.id)}"`, 'gh') : ib('x', 'Forget this board', `data-sh-forget="${esc(r.id)}"`, 's gh');
140
+ return `<div class="bdsh-card${r.state === 'open' ? '' : ' bdsh-gone'}" data-sh-recv="${esc(r.id)}"><span class="bdsh-tile" aria-hidden="true">${svg(I.board)}</span>`
141
+ + `<div class="bdsh-txt">${nm}${meta}</div>${pill}<span class="bdsh-act${r.state === 'open' ? ' open' : ''}">${act}</span></div>`;
142
+ }
143
+ function withYouHtml(S) {
144
+ const W = S.withYou || []; if (!W.length) return '';
145
+ const order = { open: 0, password: 1, locked: 1, unreachable: 2, off: 3, ended: 3 };
146
+ return `<span class="bdsh-cap">Shared with you</span><div class="bdsh-list" role="list" aria-label="Shared with you">${W.slice().sort((a, b) => (order[a.state] || 0) - (order[b.state] || 0)).map((r) => recvCard(S, r)).join('')}</div>`;
147
+ }
148
+
116
149
  /** The tab's inside. S = { phase, yours, teams:[{team,host,ok,links|kind,error}], names, readAt, now, sel, join, off, leave, pw, endedOpen } */
117
150
  export function viewHtml(S) {
118
151
  if (S.phase === 'loading') return '<p class="bdsh-meta bdsh-pad">Reading the teams…</p>';
119
152
  const teams = S.teams || []; const rows = rowsOf(S);
120
- if (!teams.length && !(S.yours || []).length) return `<div class="bdsh-col">${firstRun(S)}</div>`;
153
+ const W = S.withYou || [];
154
+ if (!teams.length && !(S.yours || []).length && !W.length) return `<div class="bdsh-col">${pasteHtml(S)}${firstRun(S)}</div>`;
121
155
  const count = (f) => rows.filter((r) => !r.ended && f(r)).length;
122
- const sel = S.sel === 'mine' && (S.yours || []).length ? 'mine' : teams.some((t) => t.team === S.sel) ? S.sel : 'all';
156
+ const sel = S.sel === 'mine' && (S.yours || []).length ? 'mine' : S.sel === 'with' && W.length ? 'with' : teams.some((t) => t.team === S.sel) ? S.sel : 'all';
123
157
  const chip = (k, label, n) => `<button type="button" class="bd-chip bdsh-chip${sel === k ? ' on' : ''}" data-sh-sel="${esc(k)}" aria-pressed="${sel === k}">${esc(label)}${n ? ' ' + n : ''}</button>`;
124
158
  const J = S.join || {};
125
159
  const plus = `<span class="bdsh-plusw">${ib('plus', 'Join a team', `data-sh="${J.open ? 'closejoin' : 'openjoin'}" aria-haspopup="dialog" aria-expanded="${!!J.open}"`, 's plus')}`
126
160
  + (J.open ? `<div class="bdsh-pop bdsh-joinpop" role="dialog" aria-label="Join a team"><div class="bdsh-ph"><b>Join a team</b>${ib('x', 'Close', 'data-sh="closejoin"', 's gh')}</div>${joinForm(J)}<p class="bdsh-meta bdsh-fine">${svg(I.lock, 14)} Kept on this computer only.</p></div>` : '') + '</span>';
127
- const chipsHtml = chip('all', 'All', count(() => true)) + ((S.yours || []).length ? chip('mine', 'Yours', count((r) => r.mine)) : '') + teams.map((t) => chip(t.team, t.team, t.ok ? count((r) => r.team === t.team) : 0)).join('') + plus;
161
+ const openW = W.filter((r) => r.state === 'open').length;
162
+ const chipsHtml = chip('all', 'All', count(() => true) + openW) + (W.length ? chip('with', 'With you', openW) : '') + ((S.yours || []).length ? chip('mine', 'Yours', count((r) => r.mine)) : '') + teams.map((t) => chip(t.team, t.team, t.ok ? count((r) => r.team === t.team) : 0)).join('') + plus;
128
163
  const failing = teams.filter((t) => !t.ok && (sel === 'all' || sel === t.team));
129
164
  const banners = failing.map((t) => `<div class="bdsh-ban" role="alert"><i class="cz-dot red"></i><span class="bdsh-bt"><b>${esc(t.team)}</b> cannot be read. ${esc(t.error)}</span>`
130
165
  + (t.kind === 'password' ? ib('key', 'Paste a new invite link', `data-sh="repaste" data-sh-team="${esc(t.team)}"`, 's') : ib('refresh', 'Refresh', `data-sh="refresh" data-sh-team="${esc(t.team)}"`, 's')) + '</div>').join('');
131
- const shown = rows.filter((r) => (sel === 'all' ? true : sel === 'mine' ? r.mine : r.team === sel)).sort((a, b) => (b.mine ? 1 : 0) - (a.mine ? 1 : 0) || String(b.made).localeCompare(String(a.made)));
166
+ const shown = rows.filter((r) => (sel === 'all' ? true : sel === 'with' ? false : sel === 'mine' ? r.mine : r.team === sel)).sort((a, b) => (b.mine ? 1 : 0) - (a.mine ? 1 : 0) || String(b.made).localeCompare(String(a.made)));
132
167
  const live = shown.filter((r) => !r.ended); const ended = shown.filter((r) => r.ended);
133
168
  const teamSel = teams.find((t) => t.team === sel);
134
169
  const leaving = teamSel && S.leave === teamSel.team;
135
170
  const teamLine = teamSel ? `<div class="bdsh-tl"><span class="bdsh-meta">${leaving ? 'Leave ' + esc(teamSel.team) + '? It only forgets the team\'s password on this computer. Copies you published stay.' : esc(teamSel.team)}</span>`
136
171
  + (leaving ? ib('x', 'Stay in the team', 'data-sh="stay"', 's gh') + ib('leave', 'Sure, leave ' + teamSel.team, `data-sh-leave="${esc(teamSel.team)}"`, 's danger')
137
172
  : ib('key', 'Paste a new invite link', `data-sh="repaste" data-sh-team="${esc(teamSel.team)}"`, 's gh') + ib('leave', 'Leave team', `data-sh-leave="${esc(teamSel.team)}"`, 's gh')) + '</div>' : '';
138
- const empty = !live.length && !ended.length && !failing.length ? `<p class="bdsh-meta bdsh-pad">${sel === 'all' ? 'Nothing is shared yet.' : sel === 'mine' ? 'You have no open links.' : 'Nobody has shared a board with ' + esc(sel) + ' yet. Press the share button on the Board to publish one.'}</p>` : '';
173
+ const withYou = sel === 'all' || sel === 'with' ? withYouHtml(S) : '';
174
+ const empty = !live.length && !ended.length && !failing.length && !withYou ? `<p class="bdsh-meta bdsh-pad">${sel === 'all' ? 'Nothing is shared yet.' : sel === 'mine' ? 'You have no open links.' : 'Nobody has shared a board with ' + esc(sel) + ' yet. Press the share button on the Board to publish one.'}</p>` : '';
139
175
  const fold = ended.length ? `<button type="button" class="bdsh-fold" data-sh="ended" aria-expanded="${!!S.endedOpen}">${svg(I.more, 16)}<span>${ended.length} ended</span></button>` + (S.endedOpen ? ended.map((r) => card(S, r)).join('') : '') : '';
140
176
  const hosts = [...new Set(teams.filter((t) => t.ok).map((t) => t.host))];
141
177
  const readLine = S.readAt ? `<p class="bdsh-meta bdsh-read"><i class="cz-dot ok"></i><span>Read${hosts.length ? ' from ' + hosts.map(esc).join(', ') : ''} ${agoText(S)}. Press a board to open its copy in a new tab.</span>${ib('refresh', 'Refresh', 'data-sh="refresh"', 's gh')}</p>` : '';
142
- return `<div class="bdsh-col"><div class="bdsh-top" role="group" aria-label="Shared">${chipsHtml}</div>` + banners + teamLine
143
- + (live.length ? `<div class="bdsh-list" role="list" aria-label="Shared boards">${live.map((r) => card(S, r)).join('')}</div>` : '') + fold + empty + readLine + '</div>';
178
+ return `<div class="bdsh-col">${pasteHtml(S)}<div class="bdsh-top" role="group" aria-label="Shared">${chipsHtml}</div>` + banners + teamLine + withYou
179
+ + (live.length && withYou ? '<span class="bdsh-cap">Boards</span>' : '') + (live.length ? `<div class="bdsh-list" role="list" aria-label="Shared boards">${live.map((r) => card(S, r)).join('')}</div>` : '') + fold + empty + readLine + '</div>';
144
180
  }
145
181
 
146
182
  export async function loadCount(cb) {
@@ -157,18 +193,21 @@ function toast(msg) {
157
193
 
158
194
  export function show(el, opts = {}) {
159
195
  css();
160
- const S = el.__sh || (el.__sh = { phase: 'loading', yours: [], teams: [], names: [], readAt: '', now: Date.now(), sel: 'all', join: { open: false, typed: false }, off: null, leave: null, pw: null, endedOpen: false });
196
+ if (opts.paste && el.__sh) el.__sh.add = { link: opts.paste };
197
+ const S = el.__sh || (el.__sh = { phase: 'loading', withYou: [], add: {}, yours: [], teams: [], names: [], readAt: '', now: Date.now(), sel: 'all', join: { open: false, typed: false }, off: null, leave: null, pw: null, endedOpen: false });
161
198
  const draw = (focus) => {
162
199
  const keep = { invite: el.querySelector('#shInvite'), host: el.querySelector('#shHost'), team: el.querySelector('#shTeam'), pass: el.querySelector('#shPass') };
200
+ const lk = el.querySelector('#shLink'); if (lk) S.add.link = lk.value;
163
201
  if (keep.invite) S.join.invite = keep.invite.value; if (keep.host) S.join.host = keep.host.value; if (keep.team) S.join.team = keep.team.value; if (keep.pass) S.join.password = keep.pass.value;
164
202
  el.innerHTML = '<h2 class="vh">Shared</h2><div id="shBody">' + viewHtml(S) + '</div>';
165
203
  const f = focus && el.querySelector(focus); if (f) f.focus();
166
204
  };
205
+ if (opts.paste && !S.add.link) S.add = { link: opts.paste };
167
206
  const read = async (keepPhase) => {
168
207
  if (!keepPhase) { S.phase = 'loading'; draw(); }
169
208
  const r = await pmGet('/api/board-publish/shared').catch(() => null);
170
209
  S.phase = 'ready'; S.now = Date.now();
171
- if (r && r.ok) { S.yours = r.yours || []; S.teams = r.teams || []; S.names = S.teams.map((t) => t.team); S.readAt = r.readAt; } else toast((r && r.error) || 'The workspace did not answer. Try again.');
210
+ if (r && r.ok) { S.yours = r.yours || []; S.withYou = r.withYou || []; S.teams = r.teams || []; S.names = S.teams.map((t) => t.team); S.readAt = r.readAt; } else toast((r && r.error) || 'The workspace did not answer. Try again.');
172
211
  if (opts.onCount) opts.onCount(totalOf(S)); draw();
173
212
  };
174
213
  if (!el.__shBound) {
@@ -183,6 +222,14 @@ export function show(el, opts = {}) {
183
222
  const b = e.target.closest('button'); if (!b) return;
184
223
  if (b.dataset.shSel) { S.sel = b.dataset.shSel; S.leave = null; S.off = null; S.pw = null; return draw(`[data-sh-sel="${CSS.escape(S.sel)}"]`); }
185
224
  const a = b.dataset.sh;
225
+ if (a === 'add') {
226
+ const link = ((el.querySelector('#shLink') || {}).value || '').trim(); const pwEl = el.querySelector('#shLinkPw');
227
+ S.add = Object.assign({}, S.add, { link, busy: true, err: '' }); draw();
228
+ const r = await pmPost('/api/board-publish/receive', { link, password: pwEl ? pwEl.value : undefined }).catch(() => null); S.add.busy = false;
229
+ if (r && r.ok) { S.add = {}; S.sel = 'all'; toast('Added ' + ((r.board && r.board.name) || 'the board') + '. Its password stays on this computer.'); return read(true); }
230
+ if (r && r.kind === 'password') { S.add.needPw = true; S.add.err = r.error; return draw('#shLinkPw'); }
231
+ S.add.err = (r && r.error) || 'The workspace did not answer. Try again.'; return draw('#shLink');
232
+ }
186
233
  if (a === 'refresh') { S.leave = null; S.pw = null; return read(false); }
187
234
  if (a === 'openjoin') { S.join = { open: true, typed: false }; return draw('#shInvite'); }
188
235
  if (a === 'closejoin') { S.join = { open: false, typed: false }; return draw('.bdsh-ib.plus'); }
@@ -229,6 +276,21 @@ export function show(el, opts = {}) {
229
276
  const r = await pmPost('/api/board-publish/off', { id: S.off }).catch(() => null); S.off = null;
230
277
  toast(r && r.ok ? 'Turned off. The copy is deleted from the host.' : (r && r.error) || 'The link could not be turned off.'); return read(true);
231
278
  }
279
+ if (b.dataset.shForget) {
280
+ const r = await pmPost('/api/board-publish/forget', { id: b.dataset.shForget }).catch(() => null);
281
+ toast(r && r.ok ? 'Forgotten on this computer.' : (r && r.error) || 'Not saved.'); return read(true);
282
+ }
283
+ if (b.dataset.shRepw) {
284
+ const row = (S.withYou || []).find((x) => x.id === b.dataset.shRepw);
285
+ S.add = { link: row ? row.url : '', needPw: true, err: '' }; return draw('#shLinkPw');
286
+ }
287
+ if (b.dataset.shVisit) {
288
+ const w = window.open('about:blank', '_blank'); if (w) { try { w.opener = null; } catch { /* none */ } }
289
+ const row = (S.withYou || []).find((x) => x.id === b.dataset.shVisit);
290
+ const r = await pmPost('/api/board-publish/visit', { id: b.dataset.shVisit }).catch(() => null);
291
+ if (r && r.ok && w) { w.location.href = r.url; toast('Opened ' + ((row && row.name) || 'the board') + ' in a new tab.'); } else { if (w) w.close(); toast((r && r.error) || 'The workspace did not answer. Try again.'); if (r && r.kind) read(true); }
292
+ return;
293
+ }
232
294
  if (b.dataset.shOpen) {
233
295
  // the tab is opened by the press itself (a popup blocker allows that), then pointed at the one-time address
234
296
  const w = window.open('about:blank', '_blank'); if (w) { try { w.opener = null; } catch { /* none */ } }
@@ -10,3 +10,25 @@ export function termTarget({ live = null, command = null, ws = null } = {}) {
10
10
  if (command && typeof command === 'string') return { copy: command, label: 'Copy the terminal command' };
11
11
  return null;
12
12
  }
13
+
14
+ /* What the terminal answers by itself (batch 27 item 5, Wawan 2026-11-04: "When open terminal, the response starts in terminal and never comes
15
+ * back to the chat"). Attaching replays the session's screen into xterm.js, and xterm.js answers every query in it: device attributes, mode
16
+ * reports, the cursor position, colours, focus in and out. Sent on as input, those answers reached Claude Code as a burst of keys, which it
17
+ * reads as a paste in its input box ("paste again to expand"), under whatever the chat typed next. The session's terminal is tmux, which has
18
+ * answered those queries already, so they are not the PM's keys and none of them goes to the session. */
19
+ const AUTO = [
20
+ /\x1b\[[?>=]?[\d;]*c/g, // device attributes (DA1, DA2, DA3)
21
+ /\x1b\[\??[\d;]*\$y/g, // a mode report (DECRQM)
22
+ /\x1b\[\d+;\d+R/g, // the cursor position (DSR 6)
23
+ /\x1b\[[0-3]n/g, // the terminal status (DSR 5)
24
+ /\x1b\[\?\d*u/g, // the keyboard protocol flags
25
+ /\x1b\[[IO]/g, // focus in and out
26
+ /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, // an OSC answer (a colour, the title)
27
+ /\x1bP[\s\S]*?\x1b\\/g, // a DCS answer (DECRQSS, XTGETTCAP)
28
+ ];
29
+ /** The part of xterm.js data the PM typed: d with the terminal's own answers taken out ('' when nothing is left). */
30
+ export function typedOnly(d) {
31
+ let s = String(d == null ? '' : d);
32
+ for (const re of AUTO) s = s.replace(re, '');
33
+ return s;
34
+ }
@@ -1475,6 +1475,16 @@ span.tg-ng { cursor: default; }
1475
1475
  .ro [hidden] { display: none !important; }
1476
1476
  .ro-by { margin: 14px 18px 0; font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
1477
1477
  .ro-by b { color: var(--cz-ink); font-weight: var(--fw-strong); }
1478
+ /* the shared-by line ends with two quiet icons, Open in my PMOS and Get PMOS; the tabs of a link of several picks sit under it (batch 27 items 1 to 3) */
1479
+ .ro-by { display: flex; align-items: center; gap: 8px; }
1480
+ .ro-by .ro-txt { flex: 1; min-width: 0; }
1481
+ .ro-ways { display: inline-flex; gap: 4px; flex: none; }
1482
+ .ro-way { width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--cz-ink2); background: var(--cz-well); }
1483
+ .ro-way:hover { color: var(--cz-ink); }
1484
+ .ro-way:focus-visible, .ro-tab:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
1485
+ .ro-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 18px 0; }
1486
+ .ro-tab { height: 34px; padding: 0 14px; border-radius: 999px; display: inline-flex; align-items: center; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); text-decoration: none; }
1487
+ .ro-tab.on { background: var(--cz-well); color: var(--cz-ink); }
1478
1488
  .ro-back { color: inherit; font-weight: var(--fw-strong); text-decoration: none; }
1479
1489
  .ro-back:hover { text-decoration: underline; }
1480
1490
  /* with a viewer open the Timeline keeps the initiative's name: no Stage, Status, Start or Next milestone */
@@ -486,6 +486,9 @@ form.hm-box textarea { min-height: 48px; border: 0; background: none; resize: no
486
486
  .hv-xt { height: 100%; box-sizing: border-box; padding: 12px 16px; overflow: auto; background: var(--cz-well); } /* term-view.js: the session's terminal drawn by xterm.js, read only */
487
487
  .hv-xt .xterm { display: inline-block; zoom: .8; } /* xterm's 15px default drawn at 12px */
488
488
  .hv-xt .xterm-viewport { background: transparent !important; }
489
+ /* the live terminal fills the whole card and fits its grid to it (term-view.js, batch 27 item 6): xterm's own type size and no zoom, so the fit measures true (as the sign-in terminal) */
490
+ .hv-xt.hv-xt-live { overflow: hidden; padding: 10px 12px; }
491
+ .hv-xt.hv-xt-live .xterm { display: block; zoom: 1; width: 100%; height: 100%; }
489
492
 
490
493
  /* ── the parts other chat modules draw inside a message: context and report chips (premium-vw-ctx, report-chat), the /goal card (goal-chat),
491
494
  * Claude's question card (chat-ask), attachments (premium-attach) ── */
@@ -103,6 +103,9 @@ export function mountChat(el, host) {
103
103
  else if (th && th.ok && th.history && (th.history.messages || []).length) conv = { ok: true, state: 'ended', messages: th.history.messages };
104
104
  } else if (t.sid) conv = await host.pmGet('/api/sessions/conversation?id=' + encodeURIComponent(t.sid)).catch(() => null);
105
105
  if (my !== C.seq || C.t !== t) return;
106
+ // the next read is set before anything is drawn, so a drawing error never stops the chat reading (batch 27 item 5: answers stopped
107
+ // reaching the chat for good after the terminal opened); a later read() clears it and sets its own
108
+ clearTimeout(C.timer); C.timer = setTimeout(read, POLL);
106
109
  C.th = th && th.ok ? th : null; C.conv = conv && conv.ok ? conv : null;
107
110
  if (host.touch && sid() && C.touched !== sid()) { C.touched = sid(); host.touch(C.touched); } // the Live pill: the PM opened it now
108
111
  C.sess = host.session ? host.session(t.sid || (C.th && C.th.session)) : null;
@@ -899,12 +899,13 @@ $('#bdBell').addEventListener('click', (e) => { if (M) Acts.open.bell(e.currentT
899
899
  $('#bdPublish').addEventListener('click', (e) => { const btn = e.currentTarget; if (M && !ALL && M.board.ws) import('./bd-publish.js').then((P) => P.open(btn, { ws: M.board.ws, board: M.board.id || M.board.ws, view })); });
900
900
 
901
901
  /* ── tabs: Initiatives and Documents (Documents has no frame yet: a plain list) ──────────────────────────────────────────────────────── */
902
+ let SH_PASTE = ''; // ?shared=<link>: Open in my PMOS on a shared board (batch 27 item 3) lands on Shared with the link in the paste field
902
903
  function setTab(t) {
903
904
  tab = t;
904
905
  $('#tabIni').setAttribute('aria-selected', String(t === 'ini')); $('#tabShared').setAttribute('aria-selected', String(t === 'shared'));
905
906
  $('#cIni').hidden = t !== 'ini'; $('#cShared').hidden = t !== 'shared';
906
907
  // Shared (initiative board-publish, slice 4): reads each joined team's host now, on opening the tab and on Refresh, and never in between
907
- if (t === 'shared') { closePanel(); import('./bd-shared.js').then((S) => { SH_FULL = true; S.show($('#cShared'), { onCount: setSharedCount }); }); }
908
+ if (t === 'shared') { closePanel(); import('./bd-shared.js').then((S) => { SH_FULL = true; const paste = SH_PASTE; SH_PASTE = ''; S.show($('#cShared'), { onCount: setSharedCount, paste }); }); }
908
909
  }
909
910
  $('#tabIni').onclick = () => setTab('ini');
910
911
  $('#tabShared').onclick = () => setTab('shared');
@@ -1219,6 +1220,11 @@ PO.mount({ programs: () => PGS, stateOf: (p) => progState(p), say,
1219
1220
  SO.mount({ redraw: () => { if (M) drawInit(); }, say });
1220
1221
  SO.load().then(() => { if (M) drawInit(); }); // the PM's sort and own order (look.order), shared with Home
1221
1222
  PP.mount({ M: () => M, programs: () => PGS, openIni: (id, from) => openIni((M.rows || []).find((x) => x.id === id && x.track), from), close: closePanel, reload: loadPrograms, say }); // the program panel (PgProgram)
1222
- setTab('ini');
1223
+ // Open in my PMOS (batch 27 item 3): the link fills the paste field and nothing is added before the PM presses +; the address forgets it at once
1224
+ if (Q0.get('shared') && !window.PMOS_READONLY) {
1225
+ SH_PASTE = String(Q0.get('shared')).slice(0, 400);
1226
+ try { const q = new URLSearchParams(location.search); q.delete('shared'); const s2 = q.toString(); history.replaceState(null, '', location.pathname + (s2 ? '?' + s2 : '')); } catch { /* none */ }
1227
+ setTab('shared');
1228
+ } else setTab('ini');
1223
1229
  load();
1224
1230
  loadPrograms();
@@ -596,7 +596,8 @@ export async function loadThread(key) {
596
596
  const ws = new URLSearchParams(location.search).get('ws');
597
597
  let th = null;
598
598
  try { const r = await fetch('/api/briefing-premium/thread?key=' + encodeURIComponent(key) + (ws ? '&ws=' + encodeURIComponent(ws) : ''), { credentials: 'same-origin' }); if (r.ok) th = await r.json(); } catch { th = null; }
599
- if (!th || !th.ok) return;
599
+ // one read that failed (the server busy, a dropped connection) tries again, so the open chat never stops taking answers (batch 27 item 5)
600
+ if (!th || !th.ok) { if (S.key === key && $('#dlg').classList.contains('on')) threadTimer = setTimeout(() => loadThread(key), 4000); return; }
600
601
  let conv = null;
601
602
  if (th.session) conv = await pmGet('/api/sessions/conversation?id=' + encodeURIComponent(th.session)).catch(() => null);
602
603
  else if (th.history && (th.history.messages || []).length) conv = { ok: true, state: 'ended', messages: th.history.messages }; // item-chat-history
@@ -15,8 +15,11 @@
15
15
  window.PMOS_READONLY = pack;
16
16
  var R = pack.responses;
17
17
  if (!q.get('ws')) {
18
+ // a pack of several picks (batch 27 item 1) opens on its first tab, a program's on its page; else on the pack's one board
19
+ var t0 = Array.isArray(pack.parts) && pack.parts[0];
18
20
  var bk = Object.keys(R).filter(function (k) { return /^\/api\/board-premium\?/.test(k); })[0]; var bm = bk && /ws=([^&]+)&board=([^&]+)/.exec(bk);
19
- if (bm) { try { history.replaceState(null, '', location.pathname + '?ws=' + bm[1] + '&board=' + bm[2] + '&pack=' + encodeURIComponent(file)); } catch (e) { /* none */ } }
21
+ var first = t0 && t0.ws ? '?ws=' + encodeURIComponent(t0.ws) + '&board=' + encodeURIComponent(t0.board || t0.ws) + (t0.program ? '&program=' + encodeURIComponent(t0.program) : '') : bm ? '?ws=' + bm[1] + '&board=' + bm[2] : '';
22
+ if (first) { try { history.replaceState(null, '', location.pathname + first + '&pack=' + encodeURIComponent(file)); } catch (e) { /* none */ } }
20
23
  }
21
24
  var norm = function (a) { var l = []; new URLSearchParams(a.search).forEach(function (v, k) { l.push(k + '=' + v); }); l.sort(); return a.pathname + '?' + l.join('&'); };
22
25
  var byNorm = {}; Object.keys(R).forEach(function (k) { byNorm[norm(new URL(k, location.href))] = R[k]; });
@@ -27,8 +27,8 @@ export const VIEW_CONTROLS = [
27
27
  // the one program page (batch 21 item 2, RdProgO): open the program, its tabs, its folds, the period of its goals, an initiative of it
28
28
  'button.tg-pgo', 'button.bt-pgo', 'button.bd-lho', '#czViewer .pv-tab', '#czViewer [data-czs-fold]', '#czViewer .pp-nd .tw', '#czViewer [data-pp="ms"]', '#czViewer [data-pp="itasks"]',
29
29
  '#czViewer [data-db="fold"]', '#czViewer [data-pp="period"]', '#czViewer [data-pp="per"]', '#czViewer [data-pp="early"]', '#czViewer [data-pp="ini"]',
30
- // the line above the Board
31
- '.ro-back',
30
+ // the line above the Board: the way back to the hub, a tab per pick (batch 27 item 1), Open in my PMOS and Get PMOS (batch 27 items 2 and 3)
31
+ '.ro-back', '.ro-tab', '.ro-way',
32
32
  ].join(',');
33
33
 
34
34
  /** The Board's static toolbar and chips: hidden, so the page's own script keeps the elements it holds. */
@@ -85,13 +85,48 @@ function refuse(e) {
85
85
  const esc = (t) => String(t == null ? '' : t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
86
86
  const KIND = { product: 'Product', program: 'Program', initiative: 'Initiative', multi: 'Selection' };
87
87
 
88
- /** The one line that says what this copy is, and the way back to the team's hub (R8). */
88
+ /** Where the workspace runs on a viewer's own computer (its default address): Open in my PMOS goes there with the link, never the password. */
89
+ export const MY_PMOS = 'http://127.0.0.1:7433/premium-board';
90
+ export const GET_PMOS = 'https://pm-os.app/welcome';
91
+ const GO = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 7l5 5-5 5M4 12h11M20 4v16"/></svg>';
92
+ const GET = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7.5 10.5L12 15l4.5-4.5M5 19h14"/></svg>';
93
+
94
+ /** The link a teammate was sent, when this page is a copy on the host (/b/<id>/pack.json). A preview in the publisher's own workspace has none. */
95
+ export function linkOf(loc) {
96
+ const pk = new URLSearchParams(loc.search).get('pack') || ''; const m = /^\/b\/([A-Za-z0-9_-]+)\/pack\.json$/.exec(pk);
97
+ return m ? loc.origin + '/b/' + m[1] : '';
98
+ }
99
+
100
+ /** The tabs of a pack of several picks (batch 27 item 1, ShA): one per pick, in pick order; one pick draws none (zero is silence).
101
+ * A tab is a link to the same copy on another board (a program's tab opens its page): the page reads the pack again, from the browser's cache. */
102
+ export function tabsHtml(pack, loc) {
103
+ const parts = (pack && Array.isArray(pack.parts) ? pack.parts : []).filter((t) => t && t.ws);
104
+ if (parts.length < 2) return '';
105
+ const q = new URLSearchParams(loc.search); const ws = q.get('ws') || parts[0].ws; const prog = q.get('program') || '';
106
+ const exact = parts.findIndex((t) => t.ws === ws && (t.program || '') === prog);
107
+ const on = exact >= 0 ? exact : parts.findIndex((t) => t.ws === ws);
108
+ const pk = q.get('pack') || '';
109
+ return '<nav class="ro-tabs" aria-label="Boards in this link">' + parts.map((t, i) => {
110
+ const href = '?ws=' + encodeURIComponent(t.ws) + '&board=' + encodeURIComponent(t.board || t.ws) + (t.program ? '&program=' + encodeURIComponent(t.program) : '') + '&pack=' + encodeURIComponent(pk);
111
+ return '<a class="ro-tab' + (i === on ? ' on' : '') + '" href="' + esc(href) + '"' + (i === on ? ' aria-current="page"' : '') + '>' + esc(t.label) + '</a>';
112
+ }).join('') + '</nav>';
113
+ }
114
+
115
+ /** The two quiet icons at the end of the line (batch 27 items 2 and 3, ShX X1): Open in my PMOS (for a viewer who runs it) and Get PMOS. */
116
+ export function waysHtml(link) {
117
+ return (link ? '<a class="ro-way" href="' + esc(MY_PMOS + '?shared=' + encodeURIComponent(link)) + '" target="_blank" rel="noopener noreferrer" aria-label="Open in my PMOS" title="Open in my PMOS (if you have it)">' + GO + '</a>' : '')
118
+ + '<a class="ro-way" href="' + GET_PMOS + '" target="_blank" rel="noopener" aria-label="Get PMOS" title="Get PMOS for yourself">' + GET + '</a>';
119
+ }
120
+
121
+ /** The one line that says what this copy is, the way back to the team's hub (R8), and back to PMOS; then the tabs when the link holds several. */
89
122
  function banner() {
90
123
  const main = document.getElementById('stage'); if (!main || !PACK || document.querySelector('.ro-by')) return;
91
124
  const s = PACK.scope || {}; const d = document.createElement('div'); d.className = 'ro-by';
92
- d.innerHTML = (PACK.hub ? '<a class="ro-back" href="' + esc(PACK.hub) + '">← Shared boards</a> · ' : '') + '<b>' + esc(s.label) + '</b> · ' + esc(KIND[s.kind] || '') + ' · a read-only copy of ' + esc(PACK.at)
93
- + ' · shared by ' + esc(PACK.by) + ' · the link ends ' + esc(PACK.ends);
125
+ d.innerHTML = '<span class="ro-txt">' + (PACK.hub ? '<a class="ro-back" href="' + esc(PACK.hub) + '">← Shared boards</a> · ' : '') + '<b>' + esc(s.label) + '</b> · ' + esc(KIND[s.kind] || '') + ' · a read-only copy of ' + esc(PACK.at)
126
+ + ' · shared by ' + esc(PACK.by) + ' · the link ends ' + esc(PACK.ends) + '</span><span class="ro-ways">' + waysHtml(linkOf(location)) + '</span>';
94
127
  main.insertBefore(d, main.firstChild);
128
+ const tabs = tabsHtml(PACK, location);
129
+ if (tabs) d.insertAdjacentHTML('afterend', tabs);
95
130
  }
96
131
 
97
132
  function start() {
@@ -4,13 +4,28 @@
4
4
  * to the session over the page's WebSocket, as the sign-in terminal does (cozy-claude-setup.js signInTerminal), so keys typed in it reach the
5
5
  * session — a first-run prompt, a pasted sign-in code, an answer to Claude. Before, it was read only, and a new user whose Claude stopped at its
6
6
  * theme or trust-this-folder screen could not answer it.
7
+ * It fills the whole card and fits its grid to it, again whenever the card or the window changes size (batch 27 item 6), and only the keys
8
+ * the PM types reach the session: xterm.js's own answers to the queries in the replayed screen do not (chat-term.js typedOnly, item 5).
7
9
  * An ENDED session, or a page where the socket cannot attach, keeps the read-only view: GET /api/sessions/transcript?id=<sid>&raw=1 every 2 s,
8
10
  * drawn in xterm.js the size of the session's own, after the first read only what was printed since (since + gen). When xterm.js cannot load,
9
11
  * it falls back to the plain text (GET …&max=12000, ANSI taken out). */
10
12
  import { open, current, setKind } from './cozy-viewer.js';
11
13
  import { pmGet, pageNonce } from './http.js';
14
+ import { typedOnly } from './chat-term.js';
12
15
 
13
- let xtermP = null;
16
+ let xtermP = null, fitP = null;
17
+ /** window.FitAddon from /vendor/addon-fit.js, loaded once. Resolves null when it cannot load: the live terminal then keeps the session's grid. */
18
+ function loadFit() {
19
+ if (window.FitAddon) return Promise.resolve(window.FitAddon);
20
+ if (fitP) return fitP;
21
+ fitP = new Promise((done) => {
22
+ const s = document.createElement('script'); s.src = '/vendor/addon-fit.js';
23
+ s.onload = () => done(window.FitAddon || null);
24
+ s.onerror = () => { fitP = null; done(null); };
25
+ document.head.appendChild(s);
26
+ });
27
+ return fitP;
28
+ }
14
29
  /** window.Terminal from /vendor/xterm.js, loaded once with its stylesheet. Resolves null when it cannot load. */
15
30
  function loadXterm() {
16
31
  if (window.Terminal) return Promise.resolve(window.Terminal);
@@ -39,14 +54,15 @@ function look(el) {
39
54
 
40
55
  /** The live terminal: attach over the WebSocket and send what the PM types. Resolves true when it attached to a running session,
41
56
  * false when the session has ended or the socket did not answer (the caller then shows the read-only view). */
42
- function attachLive(Terminal, body, sid, live, keep) {
57
+ function attachLive(Terminal, Fit, body, sid, live, keep) {
43
58
  return new Promise((done) => {
59
+ let ro = null;
44
60
  let sock;
45
61
  try { sock = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/?pmos-nonce=' + encodeURIComponent(pageNonce())); } catch { done(false); return; }
46
62
  const send = (d) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(d)); };
47
63
  let term = null, settled = false;
48
64
  const settle = (v) => { if (!settled) { settled = true; clearTimeout(t); done(v); } };
49
- const close = () => { try { send({ t: 'detach', id: sid }); sock.close(); } catch { /* gone */ } if (term) { try { term.dispose(); } catch { /* gone */ } term = null; } };
65
+ const close = () => { if (ro) { ro.disconnect(); ro = null; } try { send({ t: 'detach', id: sid }); sock.close(); } catch { /* gone */ } if (term) { try { term.dispose(); } catch { /* gone */ } term = null; } };
50
66
  const t = setTimeout(() => { close(); settle(false); }, 1500); // a session the server does not know never answers
51
67
  sock.addEventListener('open', () => send({ t: 'attach', id: sid }));
52
68
  sock.addEventListener('error', () => { close(); settle(false); });
@@ -60,14 +76,29 @@ function attachLive(Terminal, body, sid, live, keep) {
60
76
  term = new Terminal({ cols: m.cols || 80, rows: m.rows || 24, cursorBlink: true, scrollback: 5000, fontFamily: L.fontFamily, lineHeight: 1.25, theme: { ...L.theme, cursor: L.theme.foreground } });
61
77
  term.open(host);
62
78
  if (m.buf) term.write(m.buf, () => term && term.scrollToBottom());
63
- term.onData((d) => send({ t: 'input', id: sid, data: d }));
64
- // drawn at the session's own grid (as the read-only view is), so opening the card never reflows a running agent
79
+ // only the keys the PM types reach the session: the terminal's own answers to the queries in the replayed screen stay here (chat-term.js typedOnly)
80
+ term.onData((d) => { const k = typedOnly(d); if (k) send({ t: 'input', id: sid, data: k }); });
81
+ // the terminal fills the whole card and fits its lines to it, again on every resize (batch 27 item 6, Wawan 2026-11-04: "Terminal
82
+ // should occupy the whole width and height"). The session has one grid, so the agent redraws at the card's size.
83
+ if (Fit) {
84
+ const fit = new Fit.FitAddon(); term.loadAddon(fit);
85
+ let t0 = null, sent = { cols: term.cols, rows: term.rows };
86
+ const refit = () => {
87
+ if (!term || !host.isConnected) return;
88
+ try { fit.fit(); } catch { return; }
89
+ if (term.cols !== sent.cols || term.rows !== sent.rows) { sent = { cols: term.cols, rows: term.rows }; send({ t: 'resize', id: sid, cols: term.cols, rows: term.rows }); }
90
+ };
91
+ refit();
92
+ ro = new ResizeObserver(() => { clearTimeout(t0); t0 = setTimeout(refit, 80); });
93
+ ro.observe(host);
94
+ }
65
95
  keep(close);
66
96
  term.focus();
67
97
  settle(true);
68
98
  }
69
99
  if (m.t === 'data' && m.id === sid && term) { const b = term.buffer.active, atEnd = b.viewportY >= b.baseY; term.write(m.data, () => { if (atEnd && term) term.scrollToBottom(); }); }
70
- if (m.t === 'size' && m.id === sid && term) { try { term.resize(m.cols, m.rows); } catch { /* keep */ } }
100
+ // another screen resized the session: this card takes the size back at its next fit (a resize of the card or the window)
101
+ if (m.t === 'size' && m.id === sid && term && !ro) { try { term.resize(m.cols, m.rows); } catch { /* keep */ } }
71
102
  if (m.t === 'error' && !term) { close(); settle(false); }
72
103
  });
73
104
  });
@@ -117,9 +148,9 @@ export function openTermView(sid, o = {}) {
117
148
  };
118
149
 
119
150
  (async () => {
120
- const Terminal = await loadXterm();
151
+ const [Terminal, Fit] = await Promise.all([loadXterm(), loadFit()]);
121
152
  if (!Terminal) plain = true;
122
- else if (await attachLive(Terminal, body, sid, live, (c) => { closeLive = c; })) return; // a running session: the real terminal
153
+ else if (await attachLive(Terminal, Fit, body, sid, live, (c) => { closeLive = c; })) return; // a running session: the real terminal
123
154
  if (!live()) return;
124
155
  // an ended session (or no socket): the read-only view, and the card says so
125
156
  setKind(id, 'Terminal · read only');