create-pmos 0.63.0 → 0.63.2

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.63.0",
3
+ "version": "0.63.2",
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@8370b9c
1
+ kernel-source: pmos@586dc2f
2
2
  assembled: 2026-10-06
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-06T17:30:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T23:17:22Z"}
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-06T17:30:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T23:17:22Z"}
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-06T17:30:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T23:17:22Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -119,6 +119,8 @@ Every row has a visible ⋯ button. Every control is at least 44 pixels high. No
119
119
 
120
120
  You can have the same session open on your phone and your computer. A session has one size, so the screen you type in or tap last sets it. The other screen keeps showing the session and takes the size back as soon as you type in it.
121
121
 
122
+ On an iPhone or an iPad, the terminal gets smaller when the keyboard opens, so the agent's prompt stays above the keyboard. Tapping the terminal does not zoom the page, and dragging at its top or bottom does not pull or reload the page. A swipe from the left edge of the screen is Safari's Back gesture and leaves the workspace. To avoid that, add the workspace to your Home Screen and open it from there.
123
+
122
124
  ## The older layout
123
125
 
124
126
  The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
@@ -39,7 +39,14 @@ Local transcription uses whisper.cpp, a free program that runs on your machine.
39
39
 
40
40
  1. Open the settings and press **Set up in a terminal**. A terminal opens with the setup commands typed in. They install whisper.cpp with Homebrew and download the model into the workspace's own folder.
41
41
  2. Read the commands and press Enter in the terminal. The workspace does not run them for you.
42
- 3. When the download is done, open the settings again. It says **Whisper is set up**.
42
+ 3. Homebrew then says that whisper.cpp needs a model file. That is expected: the next command downloads it. Keep the terminal open until it says **Whisper is set up**.
43
+ 4. Open the settings again. It says **Whisper is set up**.
44
+
45
+ If lines come slowly, choose **Faster (about 550 MB)** under **Model** in the settings, press **Set up in a terminal** and press Enter to download it. It is the same model in a smaller form: it starts and runs faster on an M1 and makes a few more mistakes. **Most accurate (1.6 GB)** is the default.
46
+
47
+ Whisper writes in the language you choose in the settings under **Language spoken in the meeting**: English unless you change it, or Indonesian. **Detect for each line** lets Whisper guess for each few seconds of speech; it can guess wrong and write a line in another language, so choose the language when you know it.
48
+
49
+ If the download stops, press **Set up in a terminal** again and press Enter. The download continues where it stopped, and a part-downloaded model is never used.
43
50
 
44
51
  ## Use Jev for labels and checks
45
52
 
@@ -5,6 +5,8 @@
5
5
  * setupCommand(runtimeDir) → the shell commands the PM runs once: install whisper.cpp and download its model
6
6
  * createTranscriber({ runtimeDir, onText, run?, detect? }) → { push, flush, pending, stopAll }
7
7
  * wav(pcm, rate) · levels(pcm, rate) · cleanText(text)
8
+ * getLanguage(runtimeDir) · setLanguage(runtimeDir, code) the spoken language Whisper writes in (LANGUAGES)
9
+ * getModel(runtimeDir) · setModel(runtimeDir, id) the model: accurate (1.6 GB) or faster (about 550 MB) (MODELS)
8
10
  *
9
11
  * WHAT IT IS. The page captures the microphone and, when the PM picks it, a meeting tab's audio. It sends each source as
10
12
  * 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
@@ -36,8 +38,16 @@ const LEAD_MS = 300; // speech that starts a cut keeps this much
36
38
  const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
37
39
  const MAX_QUEUE = 24;
38
40
  const RUN_TIMEOUT_MS = 120000;
39
- const MODEL_FILE = 'ggml-large-v3-turbo.bin';
40
- const MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/' + MODEL_FILE;
41
+ const MODEL_BASE = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/';
42
+ // The faster model is the same one quantized to 5 bits: a third of the size, so it loads and runs faster on an M1, and it
43
+ // makes a few more mistakes. Every cut starts the program again, so the time to load the model is paid on every line.
44
+ const MODELS = Object.freeze([
45
+ Object.freeze({ id: 'accurate', file: 'ggml-large-v3-turbo.bin', size: '1.6 GB', label: 'Most accurate (1.6 GB)' }),
46
+ Object.freeze({ id: 'fast', file: 'ggml-large-v3-turbo-q5_0.bin', size: 'about 550 MB', label: 'Faster (about 550 MB)' }),
47
+ ]);
48
+ const DEFAULT_MODEL = 'accurate';
49
+ const MODEL_FILE = MODELS[0].file;
50
+ const MODEL_URL = MODEL_BASE + MODEL_FILE;
41
51
  const BIN_NAMES = ['whisper-cli', 'whisper-cpp'];
42
52
  const BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
43
53
 
@@ -55,16 +65,31 @@ function findBin(env) {
55
65
  function findModel(runtimeDir, env) {
56
66
  if (env.PMOS_WHISPER_MODEL) return isFile(env.PMOS_WHISPER_MODEL) ? env.PMOS_WHISPER_MODEL : null;
57
67
  const dir = modelDir(runtimeDir);
58
- if (isFile(path.join(dir, MODEL_FILE))) return path.join(dir, MODEL_FILE);
68
+ const chosen = modelOf(runtimeDir);
69
+ if (isFile(path.join(dir, chosen.file))) return path.join(dir, chosen.file);
70
+ // a model the PM chose and has not downloaded yet is missing, so the setup downloads it; with no choice made, any
71
+ // whole model in the folder will do
72
+ if (readSettings(runtimeDir).model) return null;
59
73
  let names = [];
60
74
  try { names = fs.readdirSync(dir).filter((f) => /^ggml-.+\.bin$/.test(f)).sort(); } catch { /* none */ }
61
75
  return names.length ? path.join(dir, names[names.length - 1]) : null;
62
76
  }
63
77
 
64
- /** The commands the PM runs once, in their own terminal. The server never runs them. */
78
+ /** The commands the PM runs once, in their own terminal. The server never runs them.
79
+ *
80
+ * Homebrew's own note after the install says that no model is downloaded; the next command is that download. It goes to a
81
+ * `.part` file that is renamed only when it is complete, so a stopped download never reads as ready, and `-C -` continues
82
+ * a stopped download when the PM runs the commands again. A program already installed is not installed again. */
65
83
  function setupCommand(runtimeDir) {
66
84
  const dir = modelDir(runtimeDir);
67
- return 'brew install whisper-cpp && mkdir -p ' + shq(dir) + ' && curl -L --fail -o ' + shq(path.join(dir, MODEL_FILE)) + ' ' + MODEL_URL;
85
+ const m = modelOf(runtimeDir);
86
+ const file = path.join(dir, m.file);
87
+ return '{ command -v whisper-cli >/dev/null 2>&1 || brew install whisper-cpp; }'
88
+ + ' && mkdir -p ' + shq(dir)
89
+ + ' && echo ' + shq('Downloading the Whisper model (' + m.size + '). Keep this terminal open.')
90
+ + ' && curl -L --fail -C - -# -o ' + shq(file + '.part') + ' ' + MODEL_BASE + m.file
91
+ + ' && mv ' + shq(file + '.part') + ' ' + shq(file)
92
+ + ' && echo ' + shq('Whisper is set up. Open the Notes settings again.');
68
93
  }
69
94
 
70
95
  /** Is local transcription ready? `missing` names what is not there: 'program', 'model'. */
@@ -74,7 +99,7 @@ function detect(runtimeDir, { env = process.env } = {}) {
74
99
  const missing = [];
75
100
  if (!bin) missing.push('program');
76
101
  if (!model) missing.push('model');
77
- return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size: '1.6 GB' };
102
+ return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size: modelOf(runtimeDir).size };
78
103
  }
79
104
 
80
105
  /** A mono 16-bit PCM buffer as a WAV file. */
@@ -109,11 +134,48 @@ function cleanText(text) {
109
134
  return t;
110
135
  }
111
136
 
137
+ /* THE SPOKEN LANGUAGE. Whisper writes in the language it is told. With 'auto' it guesses the language of each cut, and on a
138
+ * few seconds of accented speech it can guess wrong and write the line in that language, which reads as a translation. So
139
+ * the PM picks the language of the meeting once (English by default) and every cut uses it. PMOS_WHISPER_LANG wins. */
140
+ const LANGUAGES = Object.freeze([['en', 'English'], ['id', 'Indonesian'], ['auto', 'Detect for each line']]);
141
+ const DEFAULT_LANGUAGE = 'en';
142
+ const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'whisper-settings.json');
143
+ function readSettings(runtimeDir) {
144
+ try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; }
145
+ }
146
+ function writeSettings(runtimeDir, patch) {
147
+ const f = settingsFile(runtimeDir);
148
+ fs.mkdirSync(path.dirname(f), { recursive: true });
149
+ const tmp = f + '.' + process.pid + '.tmp';
150
+ fs.writeFileSync(tmp, JSON.stringify({ ...readSettings(runtimeDir), ...patch }), { mode: 0o600 });
151
+ fs.renameSync(tmp, f);
152
+ }
153
+ function getLanguage(runtimeDir, { env = process.env } = {}) {
154
+ if (env.PMOS_WHISPER_LANG) return env.PMOS_WHISPER_LANG;
155
+ const c = readSettings(runtimeDir).language;
156
+ return LANGUAGES.some(([k]) => k === c) ? c : DEFAULT_LANGUAGE;
157
+ }
158
+ function setLanguage(runtimeDir, code) {
159
+ if (!LANGUAGES.some(([k]) => k === code)) return { ok: false, error: 'the language must be one of ' + LANGUAGES.map(([k]) => k).join(', ') };
160
+ writeSettings(runtimeDir, { language: code });
161
+ return { ok: true, language: code };
162
+ }
163
+ function modelOf(runtimeDir) { const id = readSettings(runtimeDir).model; return MODELS.find((m) => m.id === id) || MODELS.find((m) => m.id === DEFAULT_MODEL); }
164
+ function getModel(runtimeDir) { return modelOf(runtimeDir).id; }
165
+ function setModel(runtimeDir, id) {
166
+ if (!MODELS.some((m) => m.id === id)) return { ok: false, error: 'the model must be one of ' + MODELS.map((m) => m.id).join(', ') };
167
+ writeSettings(runtimeDir, { model: id });
168
+ return { ok: true, model: id };
169
+ }
170
+
112
171
  /** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }. */
113
- function runWhisper({ bin, model, file, lang = process.env.PMOS_WHISPER_LANG || 'auto' }) {
114
- const threads = String(Math.max(2, Math.min(8, (os.cpus() || []).length || 4)));
172
+ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
173
+ // The GPU does most of the work on Apple silicon, so four threads are enough; more only compete with the browser.
174
+ // Greedy decoding (-bs 1 -bo 1) instead of the program's default five-way beam search makes each line several times
175
+ // faster for a small loss on hard words.
176
+ const threads = String(Math.max(2, Math.min(4, (os.cpus() || []).length || 4)));
115
177
  return new Promise((resolve) => {
116
- execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
178
+ execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads, '-bs', '1', '-bo', '1'], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
117
179
  if (e) resolve({ ok: false, error: e.killed ? 'the transcription took too long' : String((stderr || e.message || 'the program failed')).trim().split('\n').pop().slice(0, 200) });
118
180
  else resolve({ ok: true, text: String(stdout || '') });
119
181
  });
@@ -152,7 +214,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
152
214
  const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
153
215
  try {
154
216
  fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
155
- const r = await run({ bin: d.bin, model: d.model, file });
217
+ const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir) });
156
218
  if (!r.ok) { onError({ ...job, error: r.error }); return; }
157
219
  const text = cleanText(r.text);
158
220
  if (text) await onText({ ...job, text });
@@ -197,4 +259,4 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
197
259
  return { push, flush, drop, pending: () => waiting, idle: () => queue };
198
260
  }
199
261
 
200
- module.exports = { detect, setupCommand, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
262
+ module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
@@ -13,6 +13,7 @@
13
13
  * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
14
  * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
15
  * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
+ * { language?, model? } → { ok, whisper } the spoken language and the model, for this machine
16
17
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
17
18
  * POST /api/notes/private { id, private } → { ok, private }
18
19
  * POST /api/notes/summarize { id } → { ok, summary } the local summary
@@ -106,7 +107,9 @@ function transcriberFor(ctx) {
106
107
  }
107
108
  function engineInfo(ctx, w) {
108
109
  const d = whisper.detect(ctx.runtimeDir);
109
- const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size };
110
+ const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
111
+ language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
112
+ model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
110
113
  if (!w) return { whisper: out, jev: null };
111
114
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
112
115
  const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
@@ -183,6 +186,13 @@ async function handle(req, res, url, ctx) {
183
186
  if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
184
187
  if (p === '/api/notes/switch') {
185
188
  const o = wsOf();
189
+ // the spoken language belongs to this machine, not to a repository
190
+ if (body.language !== undefined || body.model !== undefined) {
191
+ const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
192
+ const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
193
+ if (!r.ok || !m.ok) { ctx.json(res, { ok: false, error: r.error || m.error }, 400); return true; }
194
+ if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
195
+ }
186
196
  if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
187
197
  jevNotes.setSwitch(dir, o.w.root, { jev: typeof body.jev === 'boolean' ? body.jev : undefined, check: typeof body.check === 'boolean' ? body.check : undefined });
188
198
  ctx.json(res, { ok: true, ...engineInfo(ctx, o.w) });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.0",
3
+ "version": "0.63.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.0",
3
+ "version": "0.63.2",
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",
@@ -43,6 +43,7 @@
43
43
  border-left: 1px solid var(--border);
44
44
  border-right: 1px solid var(--border);
45
45
  }
46
+ html.pm-vv body { height: var(--fr-vvh, 100vh); }
46
47
 
47
48
  /* Explicit placement, never auto-placement: app.js appends its root LAST, so source order would
48
49
  * put the screen underneath the bottom nav. */
@@ -363,3 +364,19 @@
363
364
  .mob[hidden], .mobmenu[hidden], .mob-back[hidden], .mob-brand[hidden] { display: none !important; }
364
365
  .mob-repo[hidden], .mob-set[hidden], .mob-sync[hidden], .mob-theme[hidden], .mobnav-n[hidden] { display: none !important; }
365
366
  .mob-deck[hidden], .mob-pager[hidden], .mob-deck__views[hidden] { display: none !important; }
367
+
368
+ /* ── a touch screen: the visible height, no page bounce, no zoom on focus ─────────────────────
369
+ * pm/mobile.js sets `pm-vv` and `--fr-vvh` (the visual viewport's height) on a touch screen. The
370
+ * page is that tall, so the soft keyboard sits under the terminal and does not cover it. Above the
371
+ * breakpoint (an iPad) that is app.js's root; at the breakpoint and below it is <body>, in the phone rules above.
372
+ * A drag at the top or bottom of the terminal does not pull the page or reload it. Every rule here
373
+ * needs `pm-vv`, so a desktop with a mouse is untouched. */
374
+ html.pm-vv, html.pm-vv body { overscroll-behavior: none; }
375
+ html.pm-vv { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
376
+ @media (min-width: 761px) {
377
+ html.pm-vv body > [data-pm-theme] { height: var(--fr-vvh, 100vh) !important; }
378
+ }
379
+ /* xterm types through a hidden textarea and gives it the terminal's 12.5px font. iOS zooms the
380
+ * page in on a focused field under 16px, which pushes the terminal off the side of the screen. The
381
+ * textarea is invisible, so its size changes nothing else. */
382
+ html.pm-vv .xterm .xterm-helper-textarea { font-size: 16px !important; }
@@ -475,13 +475,42 @@ function onChromeClick(e) {
475
475
 
476
476
  /* The terminal has to re-measure when the soft keyboard shows or hides and after a rotation, or
477
477
  * xterm keeps the row count it had and the bottom of the session is clipped. The fit itself belongs
478
- * to the terminal panel; this only tells it that the box changed. */
478
+ * to the terminal panel; this only tells it that the box changed. It calls the terminal's refit
479
+ * directly: a synthetic window `resize` also re-lays the card deck and the report slides. */
479
480
  function refit() {
480
481
  const t = window.pmTerm;
481
- if (t && typeof t.fit === 'function') { try { t.fit(); return; } catch { /* fall through */ } }
482
+ if (t && typeof t.refitVisible === 'function') { try { t.refitVisible(); return; } catch { /* fall through */ } }
482
483
  window.dispatchEvent(new Event('resize'));
483
484
  }
484
485
 
486
+ /* ── the visible height, on a touch screen ───────────────────────────────────────────────────
487
+ * iOS does not shrink the page when the soft keyboard opens. `100vh` stays the full screen, the
488
+ * keyboard covers its bottom, and that bottom is where the agent's prompt and the control keys
489
+ * are. Safari then scrolls the page to show the focused field, so the screen jumps. And with
490
+ * Safari's toolbar shown, `100vh` is already taller than what is visible.
491
+ *
492
+ * So on a touch screen the page height is the VISUAL viewport's: `--fr-vvh` on <html>, read by
493
+ * pm/mobile.css, with the page held at the top. A pinch zoom also changes the visual viewport, and
494
+ * that is not the page getting smaller, so the height is in layout pixels (height × scale). */
495
+ const TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
496
+ let vvh = 0;
497
+
498
+ function fitViewport() {
499
+ const vv = window.visualViewport;
500
+ const root = document.documentElement;
501
+ if (!vv || !TOUCH.matches) {
502
+ if (root.classList.contains('pm-vv')) { root.classList.remove('pm-vv'); vvh = 0; refit(); }
503
+ return;
504
+ }
505
+ root.classList.add('pm-vv');
506
+ if (window.scrollY || window.scrollX) window.scrollTo(0, 0);
507
+ const h = Math.round(vv.height * (vv.scale || 1));
508
+ if (!h || h === vvh) return;
509
+ vvh = h;
510
+ root.style.setProperty('--fr-vvh', h + 'px');
511
+ refit();
512
+ }
513
+
485
514
  /* ── mount ──────────────────────────────────────────────────────────────────────────────────── */
486
515
 
487
516
  /**
@@ -515,10 +544,12 @@ export function mountMobile(context) {
515
544
  document.addEventListener('click', onChromeClick);
516
545
 
517
546
  if (window.visualViewport) {
518
- window.visualViewport.addEventListener('resize', refit);
519
- window.visualViewport.addEventListener('scroll', refit);
547
+ window.visualViewport.addEventListener('resize', fitViewport);
548
+ window.visualViewport.addEventListener('scroll', fitViewport);
520
549
  }
521
- window.addEventListener('orientationchange', () => setTimeout(refit, 250));
550
+ if (TOUCH.addEventListener) TOUCH.addEventListener('change', fitViewport);
551
+ window.addEventListener('orientationchange', () => setTimeout(() => { fitViewport(); refit(); }, 250));
552
+ fitViewport();
522
553
 
523
554
  // Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
524
555
  // phone shell exists at all AND repaint the panels — a shell left mounted above 760px is a second
@@ -699,6 +699,18 @@ function syncSize(rec, claim) {
699
699
  if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
700
700
  }
701
701
 
702
+ /**
703
+ * State a grid that a refit changed, once the box stops moving. A phone's soft keyboard and a
704
+ * rotation move the box several times in a few hundred milliseconds, and every grid the PTY hears
705
+ * makes the agent redraw its whole frame. Typing still sends at once: `onData` calls syncSize
706
+ * before the keystroke, which states the latest grid and makes the queued call a no-op.
707
+ */
708
+ const SIZE_SETTLE_MS = 120;
709
+ function queueSize(rec) {
710
+ clearTimeout(rec.sizeTimer);
711
+ rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; syncSize(rec, true); }, SIZE_SETTLE_MS);
712
+ }
713
+
702
714
  /** Write a status line into the terminal itself — once per distinct message, dimmed. */
703
715
  function note(rec, line, force) {
704
716
  if (!rec.term) return;
@@ -726,8 +738,9 @@ export function refitVisible() {
726
738
  try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
727
739
  // …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
728
740
  // grid MOVES, so a pane that was measured before it had a session would otherwise never tell
729
- // the shell how wide it is. See the `attached` handler.
730
- syncSize(rec);
741
+ // the shell how wide it is. See the `attached` handler. A grid change the fit just queued
742
+ // (see queueSize) goes out when the box settles, not here.
743
+ if (!rec.sizeTimer) syncSize(rec);
731
744
  }
732
745
  };
733
746
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
@@ -891,12 +904,12 @@ function buildReal(tab) {
891
904
  term.attachCustomKeyEventHandler((e) => !((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')));
892
905
 
893
906
  const rec = {
894
- kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
907
+ kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null, sizeTimer: null,
895
908
  };
896
909
  // Typing, focusing or re-sizing THIS pane takes the session's grid back from another screen, and
897
910
  // the grid goes out before the keystroke so the agent answers it at this pane's size.
898
911
  term.onData((d) => { if (rec.sessionId) { syncSize(rec, true); send({ t: 'input', id: rec.sessionId, data: d }); } });
899
- term.onResize(() => syncSize(rec, true));
912
+ term.onResize(() => queueSize(rec));
900
913
  host.addEventListener('focusin', () => syncSize(rec, true));
901
914
  if (typeof ResizeObserver === 'function') {
902
915
  rec.ro = new ResizeObserver(() => refitVisible());
@@ -951,6 +964,7 @@ export function disposeTerm(tab) {
951
964
  * pane for the id and detaches from the orphan instead. */
952
965
  if (rec.kind !== 'real') return;
953
966
  if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
967
+ clearTimeout(rec.sizeTimer);
954
968
  if (rec.sessionId) {
955
969
  send({ t: 'detach', id: rec.sessionId });
956
970
  SOCK.attached.delete(rec.sessionId);
@@ -504,8 +504,8 @@ async function chooseSources(ctx, n) {
504
504
  }
505
505
  async function setSwitch(ctx, patch) {
506
506
  const ws = wsOf(ctx);
507
- if (!ws) return;
508
- const r = await pmPost('/api/notes/switch', { ws, ...patch });
507
+ if (!ws && patch.language === undefined && patch.model === undefined) return;
508
+ const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
509
509
  if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
510
510
  ctx.setState({});
511
511
  return r;
@@ -527,7 +527,11 @@ function openSettings(ctx, n) {
527
527
  el('div', { class: 'nt-set__head' }, 'Local transcription'),
528
528
  el('p', { class: 'nt-hint' }, !w ? 'Reading …' : w.ready ? 'Whisper is set up. The microphone and the meeting are transcribed on this machine.'
529
529
  : 'Whisper is not set up (' + w.missing.map((m) => (m === 'program' ? 'the program' : 'the model, ' + w.model_size)).join(' and ') + ' missing). The setup installs whisper.cpp with Homebrew and downloads the model.'),
530
- w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null));
530
+ w && w.models ? field({ label: 'Model', kind: 'select', value: w.model, options: w.models,
531
+ onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
532
+ w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
533
+ w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
534
+ onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null));
531
535
  if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
532
536
  else {
533
537
  const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });