create-pmos 0.53.0 → 0.53.1

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 (47) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +2 -0
  7. package/workspace/REMOTE.md +24 -63
  8. package/workspace/bin/pmos.js +4 -2
  9. package/workspace/guide/command-palette.md +1 -1
  10. package/workspace/guide/index.md +2 -1
  11. package/workspace/guide/launcher.md +51 -0
  12. package/workspace/guide/layout.md +4 -0
  13. package/workspace/guide/proactive-engine.md +21 -5
  14. package/workspace/lib/board-fold.js +2 -2
  15. package/workspace/lib/breakdown-export.js +1 -6
  16. package/workspace/lib/brief.js +20 -2
  17. package/workspace/lib/goals.js +165 -0
  18. package/workspace/lib/initiative-rows.js +7 -3
  19. package/workspace/lib/launcher-cli.js +207 -0
  20. package/workspace/lib/plan-tree.js +15 -2
  21. package/workspace/lib/pmos-verbs.js +3 -0
  22. package/workspace/lib/proactive-cli.js +58 -2
  23. package/workspace/lib/proactive.js +2 -2
  24. package/workspace/lib/qr.js +293 -0
  25. package/workspace/lib/remote-access.js +93 -1
  26. package/workspace/lib/report-draft.js +1 -2
  27. package/workspace/lib/routes-timeline.js +1 -1
  28. package/workspace/package-lock.json +1 -1
  29. package/workspace/package.json +1 -1
  30. package/workspace/public/icons/apple-touch-icon.png +0 -0
  31. package/workspace/public/icons/icon-192.png +0 -0
  32. package/workspace/public/icons/icon-512.png +0 -0
  33. package/workspace/public/index.html +11 -0
  34. package/workspace/public/pm/app.js +39 -8
  35. package/workspace/public/pm/components.css +82 -1
  36. package/workspace/public/pm/dest-today.js +8 -2
  37. package/workspace/public/pm/dock.js +73 -3
  38. package/workspace/public/pm/frame-copy.js +5 -0
  39. package/workspace/public/pm/panel-tabs.js +14 -12
  40. package/workspace/public/pm/today-copy.js +29 -0
  41. package/workspace/public/pm/today-goals.js +172 -0
  42. package/workspace/replaced-tests.json +13 -0
  43. package/workspace/server.js +134 -4
  44. package/workspace/skills/morning-brief.skill +15 -9
  45. package/workspace/test-files.json +2 -0
  46. package/workspace/test-lists/workspace-launcher.json +5 -0
  47. package/workspace/test-lists/workspace-phone-app.json +5 -0
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ /*
3
+ * qr — a QR code encoder small enough to read, for one job: the remote-mode startup banner prints
4
+ * the phone URL as a code the phone camera can scan (workspace-phone-app). Typing
5
+ * http://100.x.y.z:7433/?token=<43 characters> on a phone keyboard is the step that made the
6
+ * tailnet flow impractical.
7
+ *
8
+ * Why it is written here and not installed: the workspace ships offline (D62) and `create-pmos
9
+ * workspace` installs it with `npm install -g`, so every dependency is weight on every adopter.
10
+ * The encoder needs about 200 lines. test.js (QR-*) checks it against published vectors: the
11
+ * Reed-Solomon worked example for "HELLO WORLD" 1-M, a format string, the alignment positions and
12
+ * the data capacities. When it was written, every version 1 to 10 at both levels and with each of
13
+ * the eight masks (193 codes) was also decoded by an independent reader, zxing-cpp; that reader is
14
+ * not a dependency, so the suite does not repeat the check.
15
+ *
16
+ * Scope, on purpose: byte mode only, error-correction level M (or L), versions 1 to 10 (up to 213
17
+ * bytes at M). A URL with a token is about 80 bytes. Anything longer throws rather than producing
18
+ * a code that cannot scan. The structure follows ISO/IEC 18004 and Project Nayuki's reference
19
+ * implementation (MIT), cut down to that scope.
20
+ */
21
+
22
+ // Per version 1..10 (index 0 unused): error-correction codewords per block, and number of blocks.
23
+ const ECC = {
24
+ L: { bits: 1, perBlock: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18], blocks: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4] },
25
+ M: { bits: 0, perBlock: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26], blocks: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5] },
26
+ };
27
+ const MAX_VERSION = 10;
28
+
29
+ // ── GF(256) arithmetic and Reed-Solomon, over the QR polynomial x^8+x^4+x^3+x^2+1 (0x11D) ────────
30
+ function gfMul(x, y) {
31
+ let z = 0;
32
+ for (let i = 7; i >= 0; i--) {
33
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
34
+ z ^= ((y >>> i) & 1) * x;
35
+ }
36
+ return z & 0xff;
37
+ }
38
+ function rsDivisor(degree) {
39
+ const out = new Array(degree).fill(0);
40
+ out[degree - 1] = 1;
41
+ let root = 1;
42
+ for (let i = 0; i < degree; i++) {
43
+ for (let j = 0; j < out.length; j++) {
44
+ out[j] = gfMul(out[j], root);
45
+ if (j + 1 < out.length) out[j] ^= out[j + 1];
46
+ }
47
+ root = gfMul(root, 0x02);
48
+ }
49
+ return out;
50
+ }
51
+ function rsRemainder(data, divisor) {
52
+ const out = new Array(divisor.length).fill(0);
53
+ for (const b of data) {
54
+ const factor = b ^ out.shift();
55
+ out.push(0);
56
+ divisor.forEach((coef, i) => { out[i] ^= gfMul(coef, factor); });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // ── sizes ────────────────────────────────────────────────────────────────────────────────────
62
+ function rawDataModules(ver) {
63
+ let n = (16 * ver + 128) * ver + 64;
64
+ if (ver >= 2) {
65
+ const align = Math.floor(ver / 7) + 2;
66
+ n -= (25 * align - 10) * align - 55;
67
+ if (ver >= 7) n -= 36;
68
+ }
69
+ return n;
70
+ }
71
+ function dataCodewords(ver, ecl) {
72
+ return Math.floor(rawDataModules(ver) / 8) - ECC[ecl].perBlock[ver] * ECC[ecl].blocks[ver];
73
+ }
74
+ function alignmentPositions(ver) {
75
+ if (ver === 1) return [];
76
+ const count = Math.floor(ver / 7) + 2;
77
+ const step = Math.ceil((ver * 4 + 4) / (count * 2 - 2)) * 2;
78
+ const out = [6];
79
+ for (let pos = ver * 4 + 17 - 7; out.length < count; pos -= step) out.splice(1, 0, pos);
80
+ return out;
81
+ }
82
+
83
+ // ── data codewords: byte-mode segment, terminator, padding ───────────────────────────────────
84
+ function dataBits(bytes, ver, ecl) {
85
+ const bits = [];
86
+ const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
87
+ put(0x4, 4); // byte mode
88
+ put(bytes.length, ver <= 9 ? 8 : 16); // character count
89
+ for (const b of bytes) put(b, 8);
90
+ const capacity = dataCodewords(ver, ecl) * 8;
91
+ put(0, Math.min(4, capacity - bits.length));
92
+ put(0, (8 - (bits.length % 8)) % 8);
93
+ for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8);
94
+ const out = [];
95
+ for (let i = 0; i < bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8).join(''), 2));
96
+ return out;
97
+ }
98
+
99
+ function withEccInterleaved(data, ver, ecl) {
100
+ const numBlocks = ECC[ecl].blocks[ver];
101
+ const eccLen = ECC[ecl].perBlock[ver];
102
+ const raw = Math.floor(rawDataModules(ver) / 8);
103
+ const numShort = numBlocks - (raw % numBlocks);
104
+ const shortLen = Math.floor(raw / numBlocks);
105
+ const div = rsDivisor(eccLen);
106
+ const blocks = [];
107
+ for (let i = 0, k = 0; i < numBlocks; i++) {
108
+ const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
109
+ k += dat.length;
110
+ const ecc = rsRemainder(dat, div);
111
+ if (i < numShort) dat.push(0); // placeholder so every block has the same length
112
+ blocks.push(dat.concat(ecc));
113
+ }
114
+ const out = [];
115
+ for (let i = 0; i < blocks[0].length; i++) {
116
+ blocks.forEach((blk, j) => { if (i !== shortLen - eccLen || j >= numShort) out.push(blk[i]); });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ // ── the module grid ──────────────────────────────────────────────────────────────────────────
122
+ function formatBits(ecl, mask) {
123
+ const data = (ECC[ecl].bits << 3) | mask;
124
+ let rem = data;
125
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
126
+ return ((data << 10) | rem) ^ 0x5412;
127
+ }
128
+ function versionBits(ver) {
129
+ let rem = ver;
130
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
131
+ return (ver << 12) | rem;
132
+ }
133
+ const MASKS = [
134
+ (x, y) => (x + y) % 2 === 0,
135
+ (x, y) => y % 2 === 0,
136
+ (x) => x % 3 === 0,
137
+ (x, y) => (x + y) % 3 === 0,
138
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
139
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
140
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
141
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
142
+ ];
143
+
144
+ function build(codewords, ver, ecl, mask) {
145
+ const size = ver * 4 + 17;
146
+ const mod = Array.from({ length: size }, () => new Array(size).fill(false));
147
+ const fn = Array.from({ length: size }, () => new Array(size).fill(false));
148
+ const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
149
+ const bit = (v, i) => ((v >>> i) & 1) !== 0;
150
+
151
+ for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
152
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
153
+ for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
154
+ const d = Math.max(Math.abs(dx), Math.abs(dy));
155
+ const x = cx + dx; const y = cy + dy;
156
+ if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
157
+ }
158
+ }
159
+ const al = alignmentPositions(ver);
160
+ for (let i = 0; i < al.length; i++) for (let j = 0; j < al.length; j++) {
161
+ if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) continue;
162
+ for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
163
+ set(al[i] + dx, al[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
164
+ }
165
+ }
166
+ const drawFormat = (m) => {
167
+ const f = formatBits(ecl, m);
168
+ for (let i = 0; i <= 5; i++) set(8, i, bit(f, i));
169
+ set(8, 7, bit(f, 6)); set(8, 8, bit(f, 7)); set(7, 8, bit(f, 8));
170
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(f, i));
171
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(f, i));
172
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(f, i));
173
+ set(8, size - 8, true); // the always-dark module
174
+ };
175
+ drawFormat(0); // reserve the area; redrawn with the real mask below
176
+ if (ver >= 7) {
177
+ const v = versionBits(ver);
178
+ for (let i = 0; i < 18; i++) {
179
+ const a = size - 11 + (i % 3); const b = Math.floor(i / 3);
180
+ set(a, b, bit(v, i)); set(b, a, bit(v, i));
181
+ }
182
+ }
183
+
184
+ // Codewords in the two-column zigzag, right to left, skipping the vertical timing column.
185
+ let i = 0;
186
+ for (let right = size - 1; right >= 1; right -= 2) {
187
+ if (right === 6) right = 5;
188
+ for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
189
+ const x = right - j;
190
+ const y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;
191
+ if (!fn[y][x] && i < codewords.length * 8) {
192
+ mod[y][x] = bit(codewords[i >>> 3], 7 - (i & 7));
193
+ i++;
194
+ }
195
+ }
196
+ }
197
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
198
+ if (!fn[y][x] && MASKS[mask](x, y)) mod[y][x] = !mod[y][x];
199
+ }
200
+ drawFormat(mask);
201
+ return mod;
202
+ }
203
+
204
+ // The standard's four penalty rules, so the mask chosen is the one a camera reads most easily.
205
+ function penalty(mod) {
206
+ const size = mod.length;
207
+ let score = 0;
208
+ const lines = [];
209
+ for (let y = 0; y < size; y++) lines.push(mod[y]);
210
+ for (let x = 0; x < size; x++) lines.push(mod.map((row) => row[x]));
211
+ const finderLike = [true, false, true, true, true, false, true];
212
+ for (const line of lines) {
213
+ let run = 1;
214
+ for (let k = 1; k <= line.length; k++) {
215
+ if (k < line.length && line[k] === line[k - 1]) { run++; continue; }
216
+ if (run >= 5) score += 3 + (run - 5);
217
+ run = 1;
218
+ }
219
+ for (let k = 0; k + 7 <= line.length; k++) {
220
+ if (!finderLike.every((v, n) => line[k + n] === v)) continue;
221
+ const lightBefore = k >= 4 && [1, 2, 3, 4].every((n) => !line[k - n]);
222
+ const lightAfter = k + 11 <= line.length && [7, 8, 9, 10].every((n) => !line[k + n]);
223
+ if (lightBefore || lightAfter) score += 40;
224
+ }
225
+ }
226
+ let dark = 0;
227
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
228
+ if (mod[y][x]) dark++;
229
+ if (x + 1 < size && y + 1 < size) {
230
+ const c = mod[y][x];
231
+ if (mod[y][x + 1] === c && mod[y + 1][x] === c && mod[y + 1][x + 1] === c) score += 3;
232
+ }
233
+ }
234
+ score += 10 * Math.floor(Math.abs((dark * 100) / (size * size) - 50) / 5);
235
+ return score;
236
+ }
237
+
238
+ /**
239
+ * Encode a string as a QR code. Returns the module grid: rows of booleans, true = dark, with no
240
+ * quiet zone (the renderer adds it).
241
+ * encode('http://100.64.0.1:7433/?token=abc') → level M, smallest version that fits
242
+ * encode(text, { ecl: 'L' }) → more capacity, less damage tolerance
243
+ * encode(text, { mask: 3 }) → force a mask (tests only)
244
+ * Throws on text longer than version 10 holds at the chosen level.
245
+ */
246
+ function encode(text, { ecl = 'M', mask = null, minVersion = 1 } = {}) {
247
+ if (!ECC[ecl]) throw new Error(`qr: unsupported error-correction level ${ecl} (use M or L)`);
248
+ const bytes = [...Buffer.from(String(text), 'utf8')];
249
+ let ver = minVersion;
250
+ for (; ver <= MAX_VERSION; ver++) {
251
+ const need = 4 + (ver <= 9 ? 8 : 16) + bytes.length * 8;
252
+ if (need <= dataCodewords(ver, ecl) * 8) break;
253
+ }
254
+ if (ver > MAX_VERSION) throw new Error(`qr: ${bytes.length} bytes do not fit in a version-${MAX_VERSION} code at level ${ecl}`);
255
+ const codewords = withEccInterleaved(dataBits(bytes, ver, ecl), ver, ecl);
256
+ if (mask != null) return build(codewords, ver, ecl, mask);
257
+ let best = null; let bestScore = Infinity;
258
+ for (let m = 0; m < 8; m++) {
259
+ const grid = build(codewords, ver, ecl, m);
260
+ const s = penalty(grid);
261
+ if (s < bestScore) { best = grid; bestScore = s; }
262
+ }
263
+ return best;
264
+ }
265
+
266
+ /**
267
+ * Draw a module grid as terminal lines, two module rows per text line (half blocks), with the
268
+ * 4-module quiet zone a scanner needs.
269
+ * ansi: true explicit black-on-white colours, correct on any terminal theme (the default for a TTY)
270
+ * ansi: false no escape codes; light modules are drawn as blocks, so it reads correctly on the
271
+ * usual dark terminal and inverted on a light one (most phone cameras read both)
272
+ */
273
+ function toTerminal(grid, { ansi = true } = {}) {
274
+ const q = 4;
275
+ const size = grid.length + q * 2;
276
+ const dark = (x, y) => {
277
+ const gx = x - q; const gy = y - q;
278
+ return gx >= 0 && gy >= 0 && gx < grid.length && gy < grid.length && grid[gy][gx];
279
+ };
280
+ const lines = [];
281
+ for (let y = 0; y < size; y += 2) {
282
+ let s = '';
283
+ for (let x = 0; x < size; x++) {
284
+ const top = dark(x, y); const bottom = y + 1 < size ? dark(x, y + 1) : false;
285
+ if (ansi) s += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
286
+ else s += !top && !bottom ? '█' : !top ? '▀' : !bottom ? '▄' : ' ';
287
+ }
288
+ lines.push(ansi ? `\x1b[30;47m${s}\x1b[0m` : s);
289
+ }
290
+ return lines;
291
+ }
292
+
293
+ module.exports = { encode, toTerminal, _internal: { rsDivisor, rsRemainder, formatBits, dataCodewords, alignmentPositions } };
@@ -21,6 +21,7 @@
21
21
  const crypto = require('crypto');
22
22
  const os = require('os');
23
23
  const { spawnSync } = require('child_process');
24
+ const qr = require('./qr');
24
25
 
25
26
  const LOOPBACK = '127.0.0.1';
26
27
 
@@ -232,7 +233,16 @@ function authorize({ remoteAddress, presentedToken, expectedToken }) {
232
233
  // The startup banner lines. This is the ONE place a token may be printed, and ONLY a freshly
233
234
  // GENERATED token — a token supplied via PMOS_TOKEN is the PM's own secret and is never echoed.
234
235
  // Nothing here (or anywhere in this module) logs request headers (D08).
235
- function startupLines(bind, port) {
236
+ //
237
+ // workspace-phone-app: for a generated token the banner also prints the phone URL
238
+ // (http://<tailnet>:<port>/?token=<t>) as a QR code, because typing 43 random characters on a phone
239
+ // keyboard is the step that made the tailnet flow impractical. The QR is the same secret in another
240
+ // form, so it follows the same rule: generated token only, startup only. `ansi` picks the
241
+ // renderer (lib/qr.js toTerminal); the server passes stdout.isTTY && !NO_COLOR.
242
+ function phoneUrl(bind, port) {
243
+ return `http://${bind.tailnetIp}:${port}/?token=${encodeURIComponent(bind.token)}`;
244
+ }
245
+ function startupLines(bind, port, { ansi = false } = {}) {
236
246
  if (!bind || !bind.remote) {
237
247
  return [`pmos workspace server — http://${LOOPBACK}:${port} (loopback only, D62)`];
238
248
  }
@@ -244,16 +254,98 @@ function startupLines(bind, port) {
244
254
  if (bind.generated) {
245
255
  lines.push(` token (generated — shown ONCE, copy it now): ${bind.token}`);
246
256
  lines.push(' attach with header Authorization: Bearer <token> or query ?token=<token>');
257
+ lines.push('', ' phone: scan this with the camera of a phone on the same tailnet. It opens the workspace signed in.');
258
+ try {
259
+ for (const l of qr.toTerminal(qr.encode(phoneUrl(bind, port)), { ansi })) lines.push(' ' + l);
260
+ } catch { lines.push(' (the phone URL is too long for a QR code here; type it instead)'); }
261
+ lines.push(` or open: ${phoneUrl(bind, port)}`, '');
247
262
  } else {
248
263
  lines.push(' token: using PMOS_TOKEN from the environment (not shown)');
264
+ lines.push(` phone: open http://${bind.tailnetIp}:${port}/ and paste your PMOS_TOKEN into the sign-in field`);
249
265
  }
250
266
  lines.push(' loopback requests are token-free; every tailnet request must present the token.');
251
267
  return lines;
252
268
  }
253
269
 
270
+ // ── Sign-in for a phone (workspace-phone-app) ────────────────────────────────────────────
271
+ // Three small pieces the HTTP guard uses so that a browser on a phone never meets a JSON 401 and
272
+ // never keeps the token in its address bar. None of them changes WHO is let in: the decision is
273
+ // still authorize() above, and the Origin/Host allowlist still runs first.
274
+
275
+ // A top-level page load, as opposed to an API call, a script, or a WebSocket upgrade. Only these
276
+ // get the HTML sign-in page or the token-stripping redirect; everything else keeps its JSON answer.
277
+ function isPageNavigation({ method, accept } = {}) {
278
+ return (method === 'GET' || method === 'HEAD') && /\btext\/html\b/i.test(String(accept || ''));
279
+ }
280
+
281
+ // The URL with its `token` parameter removed, or null when it had none. A valid ?token= navigation
282
+ // seeds the cookie and is then redirected here, so the secret leaves the address bar, the history,
283
+ // and the start_url a phone records on Add to Home Screen.
284
+ function stripTokenParam(url) {
285
+ let u;
286
+ try { u = new URL(url || '/', 'http://localhost'); } catch { return null; }
287
+ if (!u.searchParams.has('token')) return null;
288
+ u.searchParams.delete('token');
289
+ const q = u.searchParams.toString();
290
+ // A path that normalises to '//host/…' would be a scheme-relative Location, which a browser reads
291
+ // as another site. Collapse leading slashes so the redirect can only ever stay on this origin.
292
+ return u.pathname.replace(/^\/+/, '/') + (q ? '?' + q : '') + u.hash;
293
+ }
294
+
295
+ // The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
296
+ // allowlist keep a foreign site from riding it. No Secure flag: the tailnet speaks plain HTTP inside
297
+ // WireGuard. 7 days, unchanged from workspace-mobile-remote-access.
298
+ function sessionCookie(token) {
299
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800`;
300
+ }
301
+
302
+ // The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
303
+ const AUTH_PATH = '/auth';
304
+ function parseAuthForm(body) {
305
+ try { return (new URLSearchParams(String(body || '')).get('token') || '').trim() || null; } catch { return null; }
306
+ }
307
+
308
+ // The page an unauthorized phone sees. Self-contained (no script, no sub-resource, so it renders
309
+ // without the cookie it is there to set) and phone-shaped. The password field lets the phone's
310
+ // password manager remember the token. The colours are the dark theme's --background/--foreground.
311
+ function signInPage({ failed = false } = {}) {
312
+ return `<!doctype html>
313
+ <html lang="en"><head><meta charset="utf-8">
314
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
315
+ <meta name="theme-color" content="#090b0c">
316
+ <title>Sign in · PMOS workspace</title>
317
+ <style>
318
+ :root { color-scheme: dark; }
319
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #090b0c; color: #f9fbfb;
320
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 24px; box-sizing: border-box; }
321
+ main { width: 100%; max-width: 360px; }
322
+ h1 { font-size: 22px; margin: 0 0 8px; }
323
+ p { margin: 0 0 16px; color: #a3abae; }
324
+ label { display: block; font-size: 14px; margin-bottom: 6px; }
325
+ input, button { width: 100%; box-sizing: border-box; min-height: 48px; font: inherit; border-radius: 6px; }
326
+ input { padding: 0 12px; background: #15191b; color: inherit; border: 1px solid #3a4245; }
327
+ button { margin-top: 12px; border: 0; background: #f9fbfb; color: #090b0c; font-weight: 600; }
328
+ .err { color: #ff8a80; }
329
+ code { font-size: 13px; }
330
+ </style></head>
331
+ <body><main>
332
+ <h1>Sign in to the PMOS workspace</h1>
333
+ ${failed ? '<p class="err" role="alert">That token does not match. Check it and try again.</p>' : ''}
334
+ <p>Paste the token that <code>PMOS_REMOTE=tailnet npm start</code> printed, or scan its QR code again.</p>
335
+ <form method="post" action="${AUTH_PATH}">
336
+ <input type="text" name="username" value="pmos" autocomplete="username" hidden>
337
+ <label for="token">Token</label>
338
+ <input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required autofocus>
339
+ <button type="submit">Sign in</button>
340
+ </form>
341
+ </main></body></html>
342
+ `;
343
+ }
344
+
254
345
  module.exports = {
255
346
  LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
256
347
  resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
257
348
  checkHostOrigin, isAllowedOrigin, hostnameAllowed,
258
349
  COOKIE_NAME, extractCookieToken, presentedToken,
350
+ phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
259
351
  };
@@ -248,10 +248,9 @@ function draftOf(root, { cadence, slot, now, health } = {}) {
248
248
  const issuedHere = iss.issues.find((r) => r.cadence === cadence && r.slot === theSlot) || null;
249
249
 
250
250
  // The work-lane records (D86 point 4: the lanes scripts/lane-defs.js groups as work; never a hard-coded list).
251
- const defs = E.lanesFor(abs).defs || {};
252
251
  const range = E.historyRange(abs);
253
252
  const workRows = E.listInitiatives(abs).map((id) => { try { return E.computeWorkflow(id, { root: abs, range }); } catch { return null; } })
254
- .filter((r) => r && r.ok && has(defs, r.lane) && defs[r.lane].group === 'work');
253
+ .filter((r) => r && r.ok && planTree.profileAndLane(abs, r.id).group === 'work');
255
254
  // A block's note marker carries its id, and readNotes reads back only NOTE_ID_RE ids: a record named outside that
256
255
  // class would lose its note on the next Redraft, so it gets no block and Sources names it instead.
257
256
  const rows = workRows.filter((r) => NOTE_ID_RE.test(r.id));
@@ -105,7 +105,7 @@ function workInitiatives(root, engine) {
105
105
  try { fm = engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {}; } catch { fm = {}; }
106
106
  const declared = String(fm.lane || '').trim();
107
107
  const lane = declared && defs[declared] ? declared : fallback;
108
- if (!defs[lane] || defs[lane].group !== 'work') continue;
108
+ if (planTree.viewGroup(defs, declared) !== 'work') continue;
109
109
  const lc = stopped.get(id);
110
110
  if (lc && lc.state !== 'active') continue;
111
111
  out.push({ id, title: String(fm.title || id), lane });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.53.0",
3
+ "version": "0.53.1",
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.53.0",
3
+ "version": "0.53.1",
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",
@@ -5,6 +5,17 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <meta name="pmos-nonce" content="" />
7
7
  <title>PMOS workspace</title>
8
+ <!-- workspace-phone-app: Add to Home Screen. The manifest is fetched WITH credentials because over the
9
+ tailnet every request needs the session cookie, and a manifest fetch sends none by default. The
10
+ apple-* tags are what iOS reads instead of the manifest. "black" keeps the page below the status
11
+ bar; the top bar does not pad for a translucent one. -->
12
+ <link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
13
+ <meta name="theme-color" content="#090b0c" />
14
+ <meta name="mobile-web-app-capable" content="yes" />
15
+ <meta name="apple-mobile-web-app-capable" content="yes" />
16
+ <meta name="apple-mobile-web-app-title" content="PMOS" />
17
+ <meta name="apple-mobile-web-app-status-bar-style" content="black" />
18
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
8
19
  <!-- The terminal's face is preloaded, not left to be discovered. An @font-face is only fetched when
9
20
  something first RENDERS in it, which for Plex Mono is about the same moment a tab constructs
10
21
  the first xterm — and xterm measures its cell from whatever face is resolved at construction.
@@ -413,7 +413,7 @@ function readFrame() {
413
413
  }
414
414
  const fr = (() => {
415
415
  const s = readFrame();
416
- return { dest: FRAME_DESTS.includes(s.dest) ? s.dest : 'today', scope: s.scope === 'all' ? 'all' : 'repo', params: {}, dock: {}, syncing: false, reg: null, gAt: 0, needs: 0 };
416
+ return { dest: FRAME_DESTS.includes(s.dest) ? s.dest : 'today', scope: s.scope === 'all' ? 'all' : 'repo', params: {}, dock: {}, dockMax: false, dockW: null, syncing: false, reg: null, gAt: 0, needs: 0 };
417
417
  })();
418
418
  function writeFrame() {
419
419
  try { localStorage.setItem(FRAME_KEY, JSON.stringify({ dest: fr.dest, scope: fr.scope })); } catch { /* private mode: the default stands */ }
@@ -421,7 +421,7 @@ function writeFrame() {
421
421
 
422
422
  const activeRepo = () => (mods.repos && typeof mods.repos.activeProduct === 'function' ? mods.repos.activeProduct() : state.product || null);
423
423
  /** The dock on the current destination: what the PM chose here with ⌘J or the rail, else open on Today only. */
424
- const dockOpen = () => (Object.prototype.hasOwnProperty.call(fr.dock, fr.dest) ? fr.dock[fr.dest] : fr.dest === 'today');
424
+ const dockOpen = () => !!fr.dockMax || (Object.prototype.hasOwnProperty.call(fr.dock, fr.dest) ? fr.dock[fr.dest] : fr.dest === 'today');
425
425
 
426
426
  function go(id, params) {
427
427
  if (!FRAME_DESTS.includes(id)) return false;
@@ -433,10 +433,39 @@ function go(id, params) {
433
433
  setState({ menuOpen: false, paletteOpen: false });
434
434
  return true;
435
435
  }
436
- function setDock(open) { fr.dock[fr.dest] = !!open; setState({}); }
436
+ function setDock(open) { fr.dock[fr.dest] = !!open; if (!open) fr.dockMax = false; setState({}); }
437
437
  const toggleDock = () => setDock(!dockOpen());
438
438
  const foldDock = () => setDock(false);
439
439
  const openDock = () => setDock(true);
440
+ /* Focus (0.53.1): the dock takes the whole window, on every destination, until the PM leaves it. It opens the dock. */
441
+ function toggleFocus() {
442
+ fr.dockMax = !fr.dockMax;
443
+ if (fr.dockMax) fr.dock[fr.dest] = true;
444
+ setState({});
445
+ }
446
+ /* The dock's width, chosen with the grip (0.53.1). Kept in this browser like the dock's other per-window choices;
447
+ * null is the default width. */
448
+ const DOCK_W_KEY = 'pmos.workspace.pm.dock-width';
449
+ function readDockW() {
450
+ if (!FRAME) return null;
451
+ let v = null;
452
+ try { v = Number(localStorage.getItem(DOCK_W_KEY)); } catch { v = null; }
453
+ return Number.isFinite(v) && v > 0 ? v : null;
454
+ }
455
+ const dockWidth = {
456
+ get: () => fr.dockW,
457
+ set: (px) => {
458
+ fr.dockW = Number.isFinite(px) && px > 0 ? Math.round(px) : null;
459
+ try { if (fr.dockW) localStorage.setItem(DOCK_W_KEY, String(fr.dockW)); else localStorage.removeItem(DOCK_W_KEY); } catch { /* private mode */ }
460
+ paintDockWidth();
461
+ },
462
+ };
463
+ function paintDockWidth() {
464
+ const v = fr.dockW ? fr.dockW + 'px' : '';
465
+ if (bodyGrid.style.getPropertyValue('--fr-dock-w') !== v) {
466
+ if (v) bodyGrid.style.setProperty('--fr-dock-w', v); else bodyGrid.style.removeProperty('--fr-dock-w');
467
+ }
468
+ }
440
469
  function chooseRepo(name) {
441
470
  fr.scope = 'repo';
442
471
  writeFrame();
@@ -498,7 +527,8 @@ function setupFrame() {
498
527
  ensureSprite();
499
528
  bar = createTopbar({ go, openPalette, repoMenu, sync: runSync });
500
529
  document.addEventListener('pointerdown', (e) => { fr.downAt = e.target; }, true);
501
- dockRail = createDockRail({ open: openDock });
530
+ dockRail = createDockRail({ open: openDock, resize: dockWidth });
531
+ fr.dockW = readDockW();
502
532
  frDetail = el('div', { class: 'fr-detail', 'data-fr': 'detail' },
503
533
  el('div', { class: 'fr-detail__bar' }, iconButton({ icon: 'x', ariaLabel: FC.detail.close, onClick: closeDetail, attrs: { 'data-fr': 'detail-close' } })),
504
534
  hostDetail);
@@ -546,7 +576,8 @@ function renderFrame() {
546
576
  fr.unsynced = unsyncedOf(st); // the phone bar (mobile.js) shows Sync on the same reading
547
577
  bar.update({ dest: fr.dest, repoLabel: fr.scope === 'all' ? FC.repo.all : repo, needs, unsynced: fr.unsynced, syncing: fr.syncing });
548
578
  const open = dockOpen();
549
- bodyGrid.setAttribute('data-fr-dock', phone ? 'phone' : open ? 'open' : 'rail');
579
+ bodyGrid.setAttribute('data-fr-dock', phone ? 'phone' : open ? (fr.dockMax ? 'max' : 'open') : 'rail');
580
+ paintDockWidth();
550
581
  const detail = !!state.col2;
551
582
  // Four children in every arrangement, hostTabs the fourth and last, so setChildren never moves it (a moved xterm loses
552
583
  // its grid) and a reader of the body's fourth child still finds panel 4. A closed detail stays mounted, hidden.
@@ -584,7 +615,7 @@ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports' };
584
615
  function frameKey(e) {
585
616
  const mod = e.metaKey || e.ctrlKey;
586
617
  if (mod && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); setState({ paletteOpen: !state.paletteOpen, menuOpen: false }); return; }
587
- if (mod && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); if (!isMobile()) toggleDock(); return; }
618
+ if (mod && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); if (!isMobile()) { if (e.shiftKey) toggleFocus(); else toggleDock(); } return; }
588
619
  if (e.key === 'Escape') {
589
620
  if (state.paletteOpen) { setState({ paletteOpen: false }); return; }
590
621
  if (state.menuOpen) { setState({ menuOpen: false }); return; }
@@ -635,9 +666,9 @@ function frameKey(e) {
635
666
 
636
667
  /* What the suites and the phone chrome reach the frame by. */
637
668
  const frameApi = {
638
- on: true, go, toggleDock, foldDock, openDock, chooseRepo, chooseAll, openPalette, repoMenu, closeDetail, sync: runSync,
669
+ on: true, go, toggleDock, foldDock, openDock, toggleFocus, dockWidth, chooseRepo, chooseAll, openPalette, repoMenu, closeDetail, sync: runSync,
639
670
  get unsynced() { return !!fr.unsynced; },
640
- get dest() { return fr.dest; }, get scope() { return fr.scope; }, get dockOpen() { return dockOpen(); },
671
+ get dest() { return fr.dest; }, get scope() { return fr.scope; }, get dockOpen() { return dockOpen(); }, get dockMax() { return !!fr.dockMax; },
641
672
  get detailOpen() { return !!state.col2; }, get syncing() { return fr.syncing; }, get needs() { return fr.needs || 0; },
642
673
  };
643
674