@floless/app 0.30.0 → 0.31.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.
package/autostart.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // autostart — register/unregister FloLess to start at LOGIN via a per-user
2
+ // "At log on" Scheduled Task (replaces the old HKCU\Run key).
3
+ //
4
+ // WHY a Scheduled Task and not the Run key (the bug this fixes):
5
+ // FloLess is installed by Velopack INSIDE Claude's MSIX container — the binary
6
+ // physically lives at
7
+ // %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\FlolessApp\…
8
+ // (there is NO copy outside the container; %LOCALAPPDATA%\FlolessApp is just a
9
+ // junction back into it). Explorer's logon Run-key processor silently SKIPS an
10
+ // executable that resides inside another package's container. Verified on the
11
+ // target machine 2026-05-30: at logon, every *other* enabled HKCU\Run entry
12
+ // fired (Shell-Core Operational event 9707 logged OneDrive, GoogleDriveFS, …)
13
+ // but the FloLess entry NEVER appeared — while the exact same command launches a
14
+ // healthy server on demand (~0.8s) and via Task Scheduler (LastTaskResult 0x0).
15
+ // It was not Fast Startup (Run keys fire fine here), not Mark-of-the-Web, not
16
+ // Defender, not a StartupApproved disable (no such entry), and not timing (key
17
+ // written before the boot). The single distinguishing factor is the container path.
18
+ //
19
+ // A per-user "At log on" Scheduled Task has none of these problems, and three
20
+ // independent research passes (n8n / Docker / Ollama / Syncthing ecosystem +
21
+ // Microsoft Learn) converge on it as THE mechanism for a per-user local server
22
+ // that must come back after every reboot:
23
+ // • Fires on every Fast-Startup power-on — a real interactive logon always
24
+ // happens (Session 1 is torn down at shutdown; only the kernel session
25
+ // hibernates). The trap to avoid is the "At startup" trigger, which the
26
+ // Fast-Startup restore path skips — so we use a LOGON trigger, never a boot one.
27
+ // • Runs as the interactive user (InteractiveToken) → the supervisor sees the
28
+ // per-user license file (%LOCALAPPDATA%\FlolessApp-data) and ~/.aware. A
29
+ // Windows Service (LocalSystem/LocalService) would NOT — wrong profile.
30
+ // • Needs no admin / no UAC / no stored password (verified: Register-ScheduledTask
31
+ // for this user's own InteractiveToken logon task succeeds unelevated here).
32
+ // • Is NOT subject to the StartupApproved silent-disable that gates Run keys.
33
+ //
34
+ // The task's action is `"<exe>" --supervise` — the same headless, self-healing
35
+ // watchdog as before (starts the server, respawns it within seconds if it dies).
36
+ // Crash-recovery is the supervisor's job, NOT the autostart mechanism's (no OS
37
+ // autostart primitive restarts a long-lived process — that's why --supervise exists).
38
+ //
39
+ // .mjs (not .ts) so it can be imported by BOTH main.ts (the SEA dispatcher, TS) and
40
+ // — if ever needed — launch.mjs (plain JS), mirroring teardown.mjs / install-path.mjs.
41
+ // Pure logic (the XML builder) is unit-tested; the imperative wrappers (schtasks.exe)
42
+ // are exercised by the human's real Shut-Down → power-on E2E.
43
+ import { execFileSync } from 'node:child_process';
44
+ import { writeFileSync, rmSync, appendFileSync, mkdirSync } from 'node:fs';
45
+ import { tmpdir } from 'node:os';
46
+ import { join } from 'node:path';
47
+ import { RUN_KEY, RUN_VALUE } from './teardown.mjs';
48
+ import { logDir, logFilePath } from './log.mjs';
49
+
50
+ // Stable per-user task name (root of the user's Task Scheduler namespace: \FloLess).
51
+ export const TASK_NAME = 'FloLess';
52
+
53
+ const isWin = process.platform === 'win32';
54
+
55
+ /**
56
+ * Is autostart a meaningful, writable setting in THIS channel? True only for the
57
+ * packaged SEA exe (FlolessApp.exe) on Windows — that's the only build whose logon
58
+ * task makes sense. The npm/dev channel runs the server via node.exe (autostart is
59
+ * never wired, and registering node.exe would be wrong), and autostart is Windows-only.
60
+ * The /api/autostart endpoint reports this as `supported`; the UI HIDES the toggle
61
+ * (never shows a dead control) when it's false.
62
+ *
63
+ * @param {string} [platform] defaults to process.platform
64
+ * @param {string} [execPath] defaults to process.execPath
65
+ * @returns {boolean}
66
+ */
67
+ export function autostartSupported(platform = process.platform, execPath = process.execPath) {
68
+ return platform === 'win32' && /flolessapp\.exe$/i.test(execPath || '');
69
+ }
70
+
71
+ /** Minimal XML text escaping (element content + the few chars a path/user could carry). */
72
+ function escapeXml(s) {
73
+ return String(s)
74
+ .replace(/&/g, '&amp;')
75
+ .replace(/</g, '&lt;')
76
+ .replace(/>/g, '&gt;')
77
+ .replace(/"/g, '&quot;')
78
+ .replace(/'/g, '&apos;');
79
+ }
80
+
81
+ /**
82
+ * Build the Task Scheduler XML for the per-user logon autostart task.
83
+ * Pure + exported so the exact task definition is unit-tested (no side effects).
84
+ *
85
+ * @param {string} exePath the (un-virtualized) FlolessApp.exe path to launch.
86
+ * @param {string} userId the principal/trigger user, e.g. "MACHINE\\User".
87
+ * @returns {string} a Task Scheduler 1.2 XML document.
88
+ */
89
+ export function buildAutostartTaskXml(exePath, userId) {
90
+ const exe = escapeXml(exePath);
91
+ const user = escapeXml(userId);
92
+ // Notes on load-bearing settings:
93
+ // LogonTrigger → fires at user logon (Fast-Startup-safe; NOT a BootTrigger).
94
+ // InteractiveToken → runs as the logged-in user with their profile loaded
95
+ // (sees the per-user license + ~/.aware); no stored password.
96
+ // LeastPrivilege → no elevation, no UAC.
97
+ // ExecutionTimeLimit PT0S → never time-limit the action (default is 72h).
98
+ // MultipleInstancesPolicy IgnoreNew → never stack supervisors.
99
+ // battery flags false → still start on laptops running on battery.
100
+ // StartWhenAvailable → run a missed trigger as soon as possible.
101
+ return `<?xml version="1.0" encoding="UTF-16"?>
102
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
103
+ <RegistrationInfo>
104
+ <Description>Keeps the FloLess local server running so your browser tab reconnects after every login.</Description>
105
+ </RegistrationInfo>
106
+ <Triggers>
107
+ <LogonTrigger>
108
+ <Enabled>true</Enabled>
109
+ <UserId>${user}</UserId>
110
+ </LogonTrigger>
111
+ </Triggers>
112
+ <Principals>
113
+ <Principal id="Author">
114
+ <UserId>${user}</UserId>
115
+ <LogonType>InteractiveToken</LogonType>
116
+ <RunLevel>LeastPrivilege</RunLevel>
117
+ </Principal>
118
+ </Principals>
119
+ <Settings>
120
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
121
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
122
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
123
+ <AllowHardTerminate>false</AllowHardTerminate>
124
+ <StartWhenAvailable>true</StartWhenAvailable>
125
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
126
+ <IdleSettings>
127
+ <StopOnIdleEnd>false</StopOnIdleEnd>
128
+ <RestartOnIdle>false</RestartOnIdle>
129
+ </IdleSettings>
130
+ <AllowStartOnDemand>true</AllowStartOnDemand>
131
+ <Enabled>true</Enabled>
132
+ <Hidden>false</Hidden>
133
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
134
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
135
+ <Priority>7</Priority>
136
+ </Settings>
137
+ <Actions Context="Author">
138
+ <Exec>
139
+ <Command>${exe}</Command>
140
+ <Arguments>--supervise</Arguments>
141
+ </Exec>
142
+ </Actions>
143
+ </Task>`;
144
+ }
145
+
146
+ /** The current interactive user as "DOMAIN\\User" (DOMAIN falls back to the machine name). */
147
+ function currentUserId() {
148
+ const dom = process.env.USERDOMAIN || process.env.COMPUTERNAME || '';
149
+ const user = process.env.USERNAME || '';
150
+ return dom ? `${dom}\\${user}` : user;
151
+ }
152
+
153
+ /** Drop the legacy HKCU\Run autostart value (the broken mechanism this replaces). Best-effort. */
154
+ function removeLegacyRunKey() {
155
+ try {
156
+ execFileSync('reg', ['delete', RUN_KEY, '/v', RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
157
+ } catch {
158
+ /* value absent — nothing to remove */
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Append one diagnostic line to the server logfile, best-effort (never throws).
164
+ * The original Run-key autostart failed SILENTLY — that is the exact failure class
165
+ * this rewrite kills, so a registration failure must always leave a breadcrumb
166
+ * (dual-review finding, 2026-05-30) rather than vanish.
167
+ */
168
+ function logLine(msg) {
169
+ try {
170
+ mkdirSync(logDir(), { recursive: true });
171
+ appendFileSync(logFilePath(), `${new Date().toISOString()} ${msg}\n`);
172
+ } catch {
173
+ /* logging must never crash the caller */
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Register (or refresh) the per-user logon autostart task to run `"<exe>" --supervise`.
179
+ * Idempotent (`/F` overwrites, so this also fixes a stale path after an update).
180
+ * Windows-only; no-op elsewhere. Also removes the legacy Run-key value (migration).
181
+ *
182
+ * Creates the task with `schtasks.exe /Create /XML` reading a UTF-16LE temp file
183
+ * directly — NOT PowerShell's `Register-ScheduledTask -Xml`. schtasks is a plain
184
+ * Win32 exe (no ScheduledTasks PS module / execution-policy / constrained-language
185
+ * dependency — important on locked-down corporate machines), it reads the UTF-16LE
186
+ * file directly — avoiding PowerShell's `-Xml` STRING round-trip that could silently
187
+ * corrupt the task definition — and makes register/query/delete all one tool.
188
+ * (schtasks REQUIRES the UTF-16 BOM: a BOM-less file is rejected as "the task XML is
189
+ * malformed" — verified 2026-05-30.) Also verified: schtasks /Create of the user's
190
+ * own InteractiveToken logon task succeeds UNELEVATED (SUCCESS, on-demand 0x0).
191
+ *
192
+ * Throws on a schtasks failure (after logging a breadcrumb) so the best-effort
193
+ * callers (the Velopack install hook, ensureAutostart) decide — they swallow it,
194
+ * but the failure is no longer invisible.
195
+ */
196
+ export function registerAutostart(exePath) {
197
+ if (!isWin) return;
198
+ const xml = buildAutostartTaskXml(exePath, currentUserId());
199
+ // pid + timestamp so two registrations in one process can't clobber each other's temp.
200
+ const tmp = join(tmpdir(), `floless-autostart-${process.pid}-${Date.now()}.xml`);
201
+ // BOM is REQUIRED: schtasks /Create rejects a BOM-less file as "the task XML is
202
+ // malformed" (it needs the U+FEFF marker to detect UTF-16). Verified 2026-05-30.
203
+ writeFileSync(tmp, '' + xml, { encoding: 'utf16le' });
204
+ try {
205
+ execFileSync('schtasks', ['/Create', '/TN', TASK_NAME, '/XML', tmp, '/F'], {
206
+ stdio: ['ignore', 'ignore', 'ignore'],
207
+ windowsHide: true,
208
+ });
209
+ } catch (err) {
210
+ logLine(`autostart: schtasks /Create failed: ${err && err.message ? err.message : err}`);
211
+ throw err;
212
+ } finally {
213
+ try { rmSync(tmp, { force: true }); } catch { /* temp cleanup best-effort */ }
214
+ }
215
+ removeLegacyRunKey();
216
+ }
217
+
218
+ /** True iff the autostart task exists. Uses schtasks' exit code (0 = present). Win-only. */
219
+ export function autostartPresent() {
220
+ if (!isWin) return false;
221
+ try {
222
+ execFileSync('schtasks', ['/query', '/tn', TASK_NAME], { stdio: 'ignore', windowsHide: true });
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Self-heal: ensure the autostart task exists, registering it if missing. Cheap and
231
+ * idempotent — called on every packaged-app open (which runs OUTSIDE Claude's MSIX
232
+ * container, where task registration is reliable), so the task is guaranteed present
233
+ * after the user opens the app even once, regardless of whether the in-container
234
+ * install hook could register it. Best-effort; never throws. Win-only.
235
+ */
236
+ export function ensureAutostart(exePath) {
237
+ if (!isWin) return;
238
+ if (autostartPresent()) return;
239
+ try { registerAutostart(exePath); } catch { /* failure already logged in registerAutostart */ }
240
+ }
241
+
242
+ /** Remove the autostart task AND any legacy Run-key value. Idempotent; Win-only. */
243
+ export function unregisterAutostart() {
244
+ if (!isWin) return;
245
+ try {
246
+ execFileSync('schtasks', ['/delete', '/tn', TASK_NAME, '/f'], { stdio: 'ignore', windowsHide: true });
247
+ } catch {
248
+ /* task absent — nothing to remove */
249
+ }
250
+ removeLegacyRunKey();
251
+ }
@@ -52835,7 +52835,7 @@ function appVersion() {
52835
52835
  return resolveVersion({
52836
52836
  isSea: isSea2(),
52837
52837
  sqVersionXml: readSqVersionXml(),
52838
- define: true ? "0.30.0" : void 0,
52838
+ define: true ? "0.31.1" : void 0,
52839
52839
  pkgVersion: readPkgVersion()
52840
52840
  });
52841
52841
  }
@@ -52845,7 +52845,7 @@ function resolveChannel(s) {
52845
52845
  return "dev";
52846
52846
  }
52847
52847
  function appChannel() {
52848
- return resolveChannel({ isSea: isSea2(), define: true ? "0.30.0" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.31.1" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
@@ -61,12 +61,30 @@
61
61
  rect.dethot{cursor:pointer;fill:rgba(168,85,247,.16);stroke:#a855f7;stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke}
62
62
  rect.dethot:hover{fill:rgba(168,85,247,.34)}
63
63
  text.dettx{fill:#ddd6fe;font:bold 11px system-ui;text-anchor:middle;pointer-events:none}
64
- #detailsModal,#framesModal,#rfiModal,#askAiModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
64
+ #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
65
65
  #askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
66
66
  .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
67
67
  .aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
68
68
  #saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
69
69
  #rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
70
+ #confStat{cursor:pointer;text-decoration:underline dotted} #confStat:hover b{filter:brightness(1.25)}
71
+ .conf-cats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px;border-bottom:1px solid var(--line)}
72
+ .ccard{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px 12px}
73
+ .ccard.click{cursor:pointer} .ccard.click:hover{border-color:var(--brand)} .ccard.on{border-color:var(--brand)}
74
+ .ccard .cc-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:4px}
75
+ .ccard .cc-score{font-size:22px;font-weight:700;line-height:1;margin-bottom:6px}
76
+ .ccard .cc-bar{height:4px;border-radius:2px;background:var(--line);margin-bottom:6px;overflow:hidden}
77
+ .ccard .cc-fill{height:100%;border-radius:2px}
78
+ .ccard .cc-counts{font-size:11px;color:var(--mut);display:flex;gap:8px;flex-wrap:wrap}
79
+ .conf-filter{display:flex;gap:12px;flex-wrap:wrap;padding:12px 14px;border-bottom:1px solid var(--line);align-items:center}
80
+ .conf-filter .glab{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
81
+ .conf-filter .grp{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
82
+ .conf-filter .grp button{border:0;border-radius:0;background:transparent;color:var(--mut);font-size:11px;padding:4px 9px;cursor:pointer}
83
+ .conf-filter .grp button.on{background:var(--brand);color:#fff}
84
+ .conf-body{padding:0 14px 14px;overflow:auto;flex:1;min-height:0}
85
+ .chip{display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;border:1px solid var(--line);color:var(--mut);margin:0 3px 3px 0}
86
+ .chip.assumed,.chip.weak{border-color:#713f12;color:#fbbf24} .chip.fail{border-color:#7f1d1d;color:#fca5a5}
87
+ tr.confrow{cursor:pointer} .conf-expand{color:var(--mut);font-size:12px;line-height:1.6;background:#0b1220}
70
88
  .ftab td .combo{padding:4px 6px} .ftab .rea{color:var(--mut);font-size:12px} .rfichip{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;background:#7f1d1d;color:#fecaca;border-radius:9px;font-size:11px;padding:0 5px}
71
89
  .mbackdrop{position:absolute;inset:0;background:rgba(2,8,23,.62)}
72
90
  .mpanel{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;width:min(880px,92vw);max-height:86vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
@@ -115,6 +133,7 @@
115
133
  <span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
116
134
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
117
135
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
136
+ <span class=stat id=confStat title="Confidence report — score each AI-read element by evidence" style="display:none">Confidence <b id=confPct>—</b></span>
118
137
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span><span style="flex:1"></span>
119
138
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
120
139
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
@@ -160,6 +179,11 @@
160
179
  <div id=rfiModal><div class=mbackdrop id=rfiBackdrop></div>
161
180
  <div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
162
181
  <div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
182
+ <div id=confModal><div class=mbackdrop id=confBackdrop></div>
183
+ <div class=mpanel><div class=mhead><b>Confidence report</b><button id=confClose>✕</button></div>
184
+ <div id=confCats class=conf-cats></div>
185
+ <div id=confFilter class=conf-filter></div>
186
+ <div id=confBody class=conf-body></div></div></div>
163
187
  <div id=askAiModal><div class=mbackdrop id=askAiBackdrop></div>
164
188
  <div class=mpanel style="width:min(560px,92vw)">
165
189
  <div class=mhead><b>Ask the AI</b><button id=askAiClose>✕</button></div>
@@ -429,7 +453,7 @@ function render(){
429
453
  selM.forEach((m,idx)=>{const c=mid(m),k=Math.round(c[0]/8)+','+Math.round(c[1]/8);(grp[k]=grp[k]||[]).push({idx,c});});
430
454
  for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
431
455
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
432
- svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup();
456
+ svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf();
433
457
  }
434
458
  function updDup(){const n=redundantDups().length;
435
459
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
@@ -500,6 +524,7 @@ function panel(){
500
524
  <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
501
525
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
502
526
  <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(len(m.wp[0],m.wp[1])/FT*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
527
+ <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
503
528
  ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
504
529
  ${elev}
505
530
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
@@ -510,7 +535,7 @@ function panel(){
510
535
  document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
511
536
  document.getElementById('rBeam').onclick=()=>{if(col)edit(()=>{m.role='beam';});};
512
537
  document.getElementById('rCol').onclick=()=>{if(!col)edit(()=>{m.role='column';});};
513
- document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.rfi=(WT[m.profile]==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}}));
538
+ document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.mf=true;m.rfi=(WT[m.profile]==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}})); // record the moment-frame provenance (drives the confidence "schedule-resolved" factor)
514
539
  const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
515
540
  if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
516
541
  if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
@@ -534,6 +559,7 @@ function panel(){
534
559
  document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});
535
560
  }
536
561
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
562
+ {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
537
563
  }
538
564
  function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const P=addProfile;const id='m'+Date.now();const pv=snapshot();
539
565
  P.members.push(ensureMeta({id,profile:P,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[P]==null)}));
@@ -643,6 +669,7 @@ addEventListener('keydown',e=>{
643
669
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
644
670
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
645
671
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
672
+ if(e.key==='Escape'&&confOpen()){closeConf();return;}
646
673
  if(e.key==='Home'){e.preventDefault();fitToWindow();return;}
647
674
  if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
648
675
  if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});return;}
@@ -780,6 +807,96 @@ function rfiOpen(){return document.getElementById('rfiModal').style.display==='f
780
807
  document.getElementById('rfiStat').onclick=openRFI;
781
808
  document.getElementById('rfiClose').onclick=closeRFI;
782
809
  document.getElementById('rfiBackdrop').onclick=closeRFI;
810
+ // ===== Confidence report — evidence-based score (MIRROR of server/steel-confidence.ts; keep in sync) =====
811
+ // Deterministic: scores ONLY observable evidence already in the contract, never a model self-report.
812
+ const BANDW={verified:1,high:.9,med:.65,low:.35,rfi:0};
813
+ const BANDLAB={verified:'Verified',high:'High',med:'Medium',low:'Low',rfi:'RFI'};
814
+ const BANDC={verified:'#86efac',high:'#4ade80',med:'#fbbf24',low:'#fca5a5',rfi:'#fca5a5'};
815
+ const BANDPILL={verified:'background:#166534;color:#86efac',high:'background:#14532d;color:#4ade80',med:'background:#713f12;color:#fbbf24',low:'background:#7f1d1d;color:#fca5a5',rfi:'background:#7f1d1d;color:#fca5a5'};
816
+ function _wt(profile){if(!WT||!profile)return null;if(profile in WT)return WT[profile]==null?null:WT[profile];
817
+ const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());return h?(h[1]==null?null:h[1]):null;}
818
+ const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
819
+ function _confDupIds(members){const g={};const key=m=>{if(!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;};
820
+ const rank=m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;};
821
+ for(const m of members){const k=key(m);if(!k)continue;(g[k]=g[k]||[]).push(m);}
822
+ const out=new Set();for(const k in g){const grp=g[k];if(grp.length<2)continue;grp.sort((a,b)=>rank(b)-rank(a));for(let i=1;i<grp.length;i++)out.add(grp[i].id);}return out;}
823
+ function _elevAssumed(m){if(m.role==='column')return !(m.col&&m.col.tosDef===false);const en=m.ends||[];if(!en.length)return true;return en.some(e=>e.tosDef!==false);}
824
+ function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
825
+ if(plf==null){F.push({key:'profile',label:'Profile',state:'fail',detail:(!m.profile||!m.profile.trim())?'no profile assigned':_isMf(m.profile)?('unresolved mark "'+m.profile+'" — not an AISC size'):('"'+m.profile+'" not in the AISC weight table')});return {band:'rfi',factors:F};}
826
+ const sched=m.mf===true;F.push({key:'profile',label:'Profile',state:sched?'assumed':'ok',detail:sched?(m.profile+' — resolved from the frame schedule ('+plf+' plf)'):(m.profile+' ('+plf+' plf, AISC)')});
827
+ const asm=_elevAssumed(m);F.push({key:'elevation',label:'Elevation',state:asm?'assumed':'ok',detail:asm?'plan default (UNO) — no local callout':'from a drawing callout'});
828
+ const isd=dup.has(m.id);if(isd)F.push({key:'dup',label:'Duplicate',state:'fail',detail:'coincident with a kept member — deleting dedupes the BOM'});
829
+ if(m.verified===true)F.push({key:'verified',label:'Verified',state:'ok',detail:'human-confirmed'});
830
+ const band=m.verified===true?'verified':isd?'low':(sched||asm)?'med':'high';return {band,factors:F};}
831
+ function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;let ft;
832
+ if(m.role==='column'){if(!m.col||m.col.tos==null||m.col.bos==null)return 0;ft=Math.abs(m.col.tos-m.col.bos)/12;} // column length = height (TOS−BOS), inches→ft
833
+ else{if(!m.wp||m.wp.length<2)return 0;ft=Math.hypot(m.wp[0][0]-m.wp[1][0],m.wp[0][1]-m.wp[1][1])/(ptPerFt>0?ptPerFt:1);}
834
+ return ft*plf/2000;}
835
+ function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
836
+ const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
837
+ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[];let segs=0;
838
+ const known=new Set();for(const p of plans)if(p.sheet)known.add(p.sheet.toUpperCase());for(const s of Object.keys(C.detail_bubbles||{}))known.add(s.toUpperCase());
839
+ plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
840
+ for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft),factors:r.factors});}
841
+ for(const d of (plan.details||[])){const sh=_detSheet(d.text);const ok=sh!=null&&known.has(sh.toUpperCase());byDetail.push({text:d.text||'',planIdx:pi,sheet:plan.sheet||'',band:ok?'high':'low',reason:ok?('references '+sh+' (in the set)'):sh?('references '+sh+' — not found in the set'):'no sheet reference parsed'});}});
842
+ const roll=(arr,cw)=>{const c=_cnt0();let n=0,d=0,t=0;for(const m of arr){c[m.band]++;if(m.band==='rfi')continue;const w=cw?1:m.tons;n+=w*BANDW[m.band];d+=w;t+=m.tons;}return {score:d>0?Math.round(n/d*100):null,tons:t,counts:c};};
843
+ const dRoll=arr=>{const c=_cnt0();let n=0,d=0;for(const x of arr){c[x.band]++;n+=BANDW[x.band];d++;}return {score:d>0?Math.round(n/d*100):null,tons:0,counts:c};};
844
+ const beams=roll(byMember.filter(m=>m.role==='beam'));const columns=roll(byMember.filter(m=>m.role==='column'));const details=dRoll(byDetail);
845
+ const sc=byMember.filter(m=>m.band!=='rfi');const n=sc.reduce((s,m)=>s+m.tons*BANDW[m.band],0),d=sc.reduce((s,m)=>s+m.tons,0);
846
+ return {byMember,byDetail,byCategory:{beams,columns,details,connections:{score:null,note:'Not scored yet (reserved slot)',counts:_cnt0()}},coverage:{members:byMember.length,segments:segs,note:'Approximate in v1 — precise per-segment binding is Slice 2.'},overall:{score:d>0?Math.round(n/d*100):null,tons:d,rfiCount:byMember.filter(m=>m.band==='rfi').length}};}
847
+ function bandColorForPct(p){return p==null?'var(--mut)':p>=95?BANDC.verified:p>=80?BANDC.high:p>=50?BANDC.med:BANDC.low;}
848
+ function updConf(){const el=document.getElementById('confStat');if(!el)return;const s=scoreContractJS();
849
+ if(!s.byMember.length){el.style.display='none';return;}el.style.display='';
850
+ const b=document.getElementById('confPct');b.textContent=s.overall.score==null?'—':s.overall.score+'%';b.style.color=bandColorForPct(s.overall.score);
851
+ el.title='Confidence — '+s.overall.tons.toFixed(1)+' t scored · '+s.overall.rfiCount+' RFI. Click for the report.';}
852
+ // --- the report modal ---
853
+ let confBand='all',confCat='all',confExpand=null;
854
+ function openConf(){confExpand=null;renderConf();document.getElementById('confModal').style.display='flex';}
855
+ function closeConf(){document.getElementById('confModal').style.display='none';}
856
+ function confOpen(){return document.getElementById('confModal').style.display==='flex';}
857
+ function _bar(pct){return '<div class=cc-bar><div class=cc-fill style="width:'+(pct||0)+'%;background:'+bandColorForPct(pct)+';'+(pct==null?'opacity:0':'')+'"></div></div>';}
858
+ function _countsHtml(c){const out=['verified','high','med','low','rfi'].filter(k=>c[k]>0).map(k=>'<span style="color:'+BANDC[k]+'">'+BANDLAB[k][0]+' <b>'+c[k]+'</b></span>').join('');return out||'<span>—</span>';}
859
+ function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
860
+ const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?' title="not scored yet"':'')+'>'+
861
+ '<div class=cc-label>'+label+'</div>'+
862
+ '<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
863
+ _bar(stub?null:cs.score)+
864
+ '<div class=cc-counts>'+(stub?'<span>reserved slot</span>':_countsHtml(cs.counts))+'</div></div>';
865
+ document.getElementById('confCats').innerHTML=card('beams','Beams',cat.beams)+card('columns','Columns',cat.columns)+card('details','Details',cat.details)+card('connections','Connections',cat.connections,true);
866
+ const bands=['all','verified','high','med','low','rfi'],cats=['all','beams','columns','details','connections'];
867
+ document.getElementById('confFilter').innerHTML=
868
+ '<span class=glab>Band</span><span class=grp>'+bands.map(b=>'<button data-band="'+b+'" class="'+(confBand===b?'on':'')+'">'+(b==='all'?'All':BANDLAB[b])+'</button>').join('')+'</span>'+
869
+ '<span class=glab>Category</span><span class=grp>'+cats.map(c=>'<button data-fcat="'+c+'" class="'+(confCat===c?'on':'')+'">'+(c==='all'?'All':c[0].toUpperCase()+c.slice(1))+'</button>').join('')+'</span>'+
870
+ '<span class=glab style="margin-left:auto">Coverage: '+s.coverage.members+' members · '+s.coverage.segments+' segments (approx, v1)</span>';
871
+ document.getElementById('confBody').innerHTML=confBodyHtml(s);
872
+ wireConf();}
873
+ function confBodyHtml(s){
874
+ if(!s.byMember.length&&!s.byDetail.length)return '<div class=hint style="padding:14px 0">No members in the takeoff yet.</div>';
875
+ if(confCat==='connections')return '<div class=hint style="padding:14px 0">Connection scoring is not built yet (reserved slot).</div>';
876
+ let mem=s.byMember.slice();
877
+ if(confCat==='beams')mem=mem.filter(m=>m.role==='beam');else if(confCat==='columns')mem=mem.filter(m=>m.role==='column');else if(confCat==='details')mem=[];
878
+ if(confBand!=='all')mem=mem.filter(m=>m.band===confBand);
879
+ let det=(confCat==='all'||confCat==='details')?s.byDetail.slice():[];
880
+ if(confBand!=='all')det=det.filter(d=>d.band===confBand);
881
+ let h='<table class=ftab><thead><tr><th>Band</th><th>Element</th><th>Sheet</th><th>Tons</th><th>Evidence</th><th></th></tr></thead><tbody>';
882
+ mem.forEach(m=>{const chips=m.factors.map(f=>'<span class="chip '+(f.state==='ok'?'':f.state)+'">'+esc(f.label)+(f.state==='ok'?' ✓':f.state==='fail'?' ✗':' ●')+'</span>').join('');
883
+ h+='<tr class=confrow data-mid="'+esc(m.id)+'" data-pi="'+m.planIdx+'"><td><span class=pill style="'+BANDPILL[m.band]+'">'+BANDLAB[m.band]+'</span></td>'+
884
+ '<td>'+(esc(m.profile)||'<span style="color:var(--mut)">(no profile)</span>')+' <span style="color:var(--mut);font-size:11px">'+esc(m.role)+'</span></td>'+
885
+ '<td>'+esc(m.sheet)+'</td><td>'+(m.tons?m.tons.toFixed(2):'—')+'</td><td>'+chips+'</td>'+
886
+ '<td><button class=ghost data-loc="'+esc(m.id)+'" data-pi="'+m.planIdx+'">Locate</button></td></tr>';
887
+ if(confExpand===m.id)h+='<tr><td></td><td colspan=5 class=conf-expand>'+m.factors.map(f=>'<div>• <b>'+esc(f.label)+':</b> '+esc(f.detail)+'</div>').join('')+'</td></tr>';});
888
+ det.forEach(d=>{h+='<tr><td><span class=pill style="'+BANDPILL[d.band]+'">'+BANDLAB[d.band]+'</span></td><td>'+(esc(d.text)||'(detail)')+' <span style="color:var(--mut);font-size:11px">detail</span></td><td>'+esc(d.sheet)+'</td><td>—</td><td><span class=hint>'+esc(d.reason)+'</span></td><td></td></tr>';});
889
+ if(!mem.length&&!det.length)h+='<tr><td colspan=6><div class=hint style="padding:14px 0">Nothing matches this filter.</div></td></tr>';
890
+ h+='</tbody></table>';return h;}
891
+ function wireConf(){
892
+ document.querySelectorAll('#confCats .ccard.click').forEach(c=>c.onclick=()=>{confCat=(confCat===c.dataset.cat?'all':c.dataset.cat);renderConf();});
893
+ document.querySelectorAll('#confFilter [data-band]').forEach(b=>b.onclick=()=>{confBand=b.dataset.band;renderConf();});
894
+ document.querySelectorAll('#confFilter [data-fcat]').forEach(b=>b.onclick=()=>{confCat=b.dataset.fcat;renderConf();});
895
+ document.querySelectorAll('#confBody tr.confrow').forEach(r=>r.onclick=e=>{if(e.target.closest('button'))return;const id=r.dataset.mid;confExpand=confExpand===id?null:id;renderConf();});
896
+ document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
897
+ document.getElementById('confStat').onclick=openConf;
898
+ document.getElementById('confClose').onclick=closeConf;
899
+ document.getElementById('confBackdrop').onclick=closeConf;
783
900
  // --- detail preview lightbox (click a card / an end's ⤢ to enlarge) ---
784
901
  const SHEET_PREV=C.sheet_previews||{}, DBUB=C.detail_bubbles||{};
785
902
  function sheetOf(text){const m=/(S-?\w+)/.exec(String(text||'').replace(/^\w+-/,''));if(!m)return null;const s=m[1].toUpperCase();return s.startsWith('S-')?s:'S-'+s.slice(1);}
@@ -0,0 +1,129 @@
1
+ // install-path — escape Claude's MSIX bind-link virtualization for paths we
2
+ // REGISTER with Windows (Run key, floless:// handler, Scheduled Task) and for
3
+ // the license-store directory.
4
+ //
5
+ // Claude desktop ships as a Windows MSIX package (`Claude_pzs8sxrjxfjjc`) that
6
+ // virtualizes `%LOCALAPPDATA%` for processes running inside its container. A
7
+ // Velopack install targeting `%LOCALAPPDATA%\FlolessApp\` actually lands at
8
+ // `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\FlolessApp\`.
9
+ // From INSIDE the container both paths appear via a transparent bind-link;
10
+ // from OUTSIDE only the Packages\... path exists. So registering Windows-level
11
+ // hooks with `process.execPath` (the apparent virtual path) makes them fail at
12
+ // logon / from Explorer / from the browser's protocol launch with
13
+ // ERROR_FILE_NOT_FOUND (0x80070002). The fix is to register the un-virtualized
14
+ // REAL path. (Verified on this machine 2026-05-30: `fs.realpathSync` does NOT
15
+ // un-virtualize — the bind-link is opaque to it — so we explicitly remap.)
16
+ //
17
+ // .mjs (not .ts) so launch.mjs (plain JS, shipped to npm as-is) can import it.
18
+ // Pure functions accept dependency-injected fs helpers so they unit-test
19
+ // without touching the real filesystem. Thin wrappers bind the real fs at the
20
+ // edge.
21
+
22
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
23
+
24
+ /**
25
+ * @param {string} execPath the path to (possibly) resolve.
26
+ * @param {string | undefined} localAppData the `%LOCALAPPDATA%` env var (apparent value).
27
+ * @param {(p: string) => boolean} exists `fs.existsSync` injection.
28
+ * @param {(p: string) => string[]} readdir `fs.readdirSync` injection (Packages enumeration).
29
+ * @param {(p: string) => string} realpath `fs.realpathSync` injection (may throw on failure).
30
+ * @returns {string} the resolved real path, or `execPath` unchanged when no remap applies.
31
+ */
32
+ export function resolveRealInstallExePure(execPath, localAppData, exists, readdir, realpath) {
33
+ if (!execPath) return execPath;
34
+ // 1. realpath handles real symlinks/junctions cleanly (forward-compatible with
35
+ // a future non-container install). Defensive: if realpath throws (e.g. on
36
+ // a path that doesn't exist), keep the original — never block on this step.
37
+ let resolved = execPath;
38
+ try { resolved = realpath(execPath); } catch { resolved = execPath; }
39
+ if (resolved !== execPath) return resolved;
40
+
41
+ // 2. Container detection + remap. Only applies when execPath sits under the
42
+ // apparent %LOCALAPPDATA% (i.e. we were launched from inside the container's
43
+ // virtualized view). On non-Windows / non-container installs this returns
44
+ // execPath unchanged.
45
+ if (!localAppData) return execPath;
46
+ // Normalize any trailing separator(s) on the env-var value before the prefix
47
+ // check — Windows env vars carry whatever the user/installer wrote and a
48
+ // trailing `\` would otherwise produce `...local\\` and silently fail to
49
+ // match `...Local\FlolessApp\…` (dual-review finding #3, 2026-05-30).
50
+ const laRoot = localAppData.replace(/[\\/]+$/, '');
51
+ const lcExec = execPath.toLowerCase();
52
+ const lcPrefix = laRoot.toLowerCase() + '\\';
53
+ if (!lcExec.startsWith(lcPrefix)) return execPath;
54
+ const tail = execPath.slice(laRoot.length); // e.g. \FlolessApp\current\FlolessApp.exe
55
+
56
+ // Already a Packages\<pkg>\LocalCache\Local\... path (i.e. the REAL location)?
57
+ // Don't double-map. Also handles the case where outside-container launches
58
+ // arrive here with the real path already.
59
+ if (/^\\Packages\\[^\\]+\\LocalCache\\Local\\/i.test(tail)) return execPath;
60
+
61
+ const packagesDir = `${laRoot}\\Packages`;
62
+ let packages;
63
+ try { packages = readdir(packagesDir); } catch { return execPath; }
64
+ if (!Array.isArray(packages)) return execPath;
65
+ for (const pkg of packages) {
66
+ const candidate = `${packagesDir}\\${pkg}\\LocalCache\\Local${tail}`;
67
+ if (exists(candidate)) return candidate;
68
+ }
69
+ return execPath;
70
+ }
71
+
72
+ /**
73
+ * Pure: if `execPath` is under an MSIX package's `\Packages\<pkg>\LocalCache\Local\`
74
+ * (i.e. we were launched from OUTSIDE the container at the real path), derive
75
+ * `<pkg>\LocalCache\Local\FlolessApp-data` as the matching license-store
76
+ * directory. This is how a logon Scheduled Task / Run key launch (which sees
77
+ * `%LOCALAPPDATA%` as the REAL user LocalAppData, not the sandbox) still finds
78
+ * the license file Velopack wrote into the sandbox.
79
+ *
80
+ * @param {string} execPath
81
+ * @returns {string | null}
82
+ */
83
+ export function sandboxLicenseDirPure(execPath) {
84
+ if (!execPath) return null;
85
+ const m = execPath.match(/^(.*\\Packages\\[^\\]+\\LocalCache\\Local)\\/i);
86
+ return m ? `${m[1]}\\FlolessApp-data` : null;
87
+ }
88
+
89
+ // ── Real-fs wrappers ────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * @param {string} [execPath] defaults to `process.execPath`
93
+ * @returns {string}
94
+ */
95
+ export function resolveRealInstallExe(execPath = process.execPath) {
96
+ return resolveRealInstallExePure(
97
+ execPath,
98
+ process.env.LOCALAPPDATA,
99
+ existsSync,
100
+ readdirSync,
101
+ realpathSync,
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Side-effect: when `process.execPath` reveals we were launched from the
107
+ * sandbox-real path (i.e. by a logon Scheduled Task / Run key sitting outside
108
+ * the container), set `FLOLESS_LICENSE_DIR` to the sandbox `FlolessApp-data`
109
+ * dir BEFORE any module reads it. Idempotent and respectful of an
110
+ * already-set env var (tests + the dev workflow). No-op in dev (tsx) and
111
+ * for non-container installs (execPath doesn't match the Packages\… shape).
112
+ */
113
+ export function deriveSandboxLicenseDir() {
114
+ if (process.env.FLOLESS_LICENSE_DIR) return;
115
+ const d = sandboxLicenseDirPure(process.execPath);
116
+ if (d) process.env.FLOLESS_LICENSE_DIR = d;
117
+ }
118
+
119
+ // Self-execute the side effect at module-evaluation time. ESM evaluates an
120
+ // imported module BEFORE control returns to subsequent imports in the
121
+ // importer, so making this top-level (rather than a call inside main.ts's
122
+ // body) guarantees FLOLESS_LICENSE_DIR is set before licensing.ts (a
123
+ // transitive import via main.ts → index.js) evaluates — even if some future
124
+ // rewrite of licensing.ts reads the env var at module-load time rather than
125
+ // lazily in storeDir(). Importing this module FIRST in main.ts preserves this
126
+ // ordering. Idempotent + no-op when the var is already set or execPath isn't a
127
+ // sandbox-real path, so the side effect is safe in every channel (SEA, npm,
128
+ // tests).
129
+ deriveSandboxLicenseDir();
package/log.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Shared logfile location + rotation for the floless.app server. Dependency-free
2
+ // (Node built-ins only) so BOTH launch.mjs (which routes the detached daemon's
3
+ // stdout/stderr here) and index.ts (which records crash stacks) resolve ONE log
4
+ // path — they must agree, or the daemon's output and the crash trace would land in
5
+ // different files and "why did it vanish?" stays unanswerable.
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { existsSync, mkdirSync, statSync, renameSync, openSync } from 'node:fs';
9
+
10
+ const MAX_BYTES = 2 * 1024 * 1024; // roll once to .1 past 2 MB
11
+
12
+ // Where logs live: FLOLESS_LOG_DIR override (tests), else the licensing store's home
13
+ // (%LocalAppData%\FlolessApp-data on Windows, ~/.aware elsewhere) under /logs.
14
+ export function logDir() {
15
+ if (process.env.FLOLESS_LOG_DIR) return process.env.FLOLESS_LOG_DIR;
16
+ if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
17
+ return join(process.env.LOCALAPPDATA, 'FlolessApp-data', 'logs');
18
+ }
19
+ return join(homedir(), '.aware', 'logs');
20
+ }
21
+
22
+ export function logFilePath() {
23
+ return join(logDir(), 'floless-server.log');
24
+ }
25
+
26
+ export function ensureLogDir() {
27
+ try { mkdirSync(logDir(), { recursive: true }); } catch { /* best-effort */ }
28
+ }
29
+
30
+ // Roll an oversized log to .1 (replacing any previous .1). Best-effort; never throws.
31
+ // Safe to call from launch.mjs (before it opens the daemon's stdio fd) AND from the
32
+ // server at startup: the second call no-ops because the file is then freshly small.
33
+ export function rotateLog() {
34
+ try {
35
+ const p = logFilePath();
36
+ if (existsSync(p) && statSync(p).size > MAX_BYTES) renameSync(p, p + '.1');
37
+ } catch { /* best-effort */ }
38
+ }
39
+
40
+ // Open the logfile for appending (creating the dir first). Returns an fd, or null if
41
+ // it can't be opened — the caller then falls back to 'ignore' rather than failing.
42
+ export function openLogFd() {
43
+ try {
44
+ ensureLogDir();
45
+ return openSync(logFilePath(), 'a');
46
+ } catch { return null; }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {
@@ -8,8 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "launch.mjs",
12
- "teardown.mjs",
11
+ "*.mjs",
13
12
  "dist/",
14
13
  "package.json"
15
14
  ],
@@ -0,0 +1,83 @@
1
+ // One-shot, time-fresh marker signalling "the next launch is a post-self-update relaunch" (#37).
2
+ //
3
+ // After a Velopack self-update, Update.exe swaps current\ and relaunches FlolessApp.exe
4
+ // **bare** → the launcher defaults to `open` → a SECOND browser tab, while the user's existing
5
+ // tab is meant to adopt the new build in place. The marker tells cmdOpen to start the server
6
+ // WITHOUT opening a browser; the existing tab reloads itself onto the new build (see the health
7
+ // poll in web/aware.js).
8
+ //
9
+ // Two hard-won constraints shape the path + freshness:
10
+ //
11
+ // 1. WRITER ≠ READER, ACROSS AN MSIX BOUNDARY. The marker is dropped by the build completing
12
+ // the update (the updater process and/or Velopack's `--veloapp-updated` hook) and read by a
13
+ // DIFFERENT process (the bare relaunch's cmdOpen). In the Claude desktop install the hook
14
+ // runs INSIDE the MSIX container (virtualized %LOCALAPPDATA%) while the relaunch runs
15
+ // OUTSIDE it — so an execPath-derived path can disagree between writer and reader. We anchor
16
+ // the marker at ~/.floless (FLOLESS_HOME), which is OUTSIDE the virtualized LocalAppData
17
+ // bind-link and therefore identical in and out of the container. (We still ALSO read the
18
+ // legacy install-root location 0.12.1 wrote to, so an update FROM 0.12.1 is honored.)
19
+ //
20
+ // 2. TIME-FRESH, NOT JUST ONE-SHOT. If a marker is dropped but the relaunch never reaches
21
+ // cmdOpen (a failed/odd update, or Velopack writing the hook AFTER the relaunch), a stale
22
+ // marker must NOT silently swallow the browser on the user's next MANUAL open. Only a marker
23
+ // dropped within the last couple of minutes counts; either way it's consumed (deleted).
24
+ import { existsSync, writeFileSync, rmSync, statSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+
28
+ const FRESH_MS = 120_000;
29
+
30
+ /** Container-stable primary location (~/.floless/.post-update). FLOLESS_POST_UPDATE_MARKER overrides it (tests). */
31
+ export function markerPath() {
32
+ const override = (process.env.FLOLESS_POST_UPDATE_MARKER ?? '').trim();
33
+ if (override) return override;
34
+ const root = process.env.FLOLESS_HOME ?? join(homedir(), '.floless');
35
+ return join(root, '.post-update');
36
+ }
37
+
38
+ /** Legacy location 0.12.1 wrote to (install root, execPath-derived). Read for back-compat. */
39
+ function legacyMarkerPath() {
40
+ if ((process.env.FLOLESS_POST_UPDATE_MARKER ?? '').trim()) return null; // tests pin a single path
41
+ try {
42
+ return join(dirname(dirname(process.execPath)), '.floless-post-update');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** Best-effort: drop the one-shot marker before a self-update relaunch. Never throws. */
49
+ export function writePostUpdateMarker() {
50
+ let wrote = false;
51
+ for (const p of [markerPath(), legacyMarkerPath()]) {
52
+ if (!p) continue;
53
+ try {
54
+ writeFileSync(p, new Date().toISOString());
55
+ wrote = true;
56
+ } catch {
57
+ /* worst case: the pre-fix extra tab — never block the update on this */
58
+ }
59
+ }
60
+ return wrote;
61
+ }
62
+
63
+ /**
64
+ * Best-effort one-shot read: returns true iff a FRESH marker existed (dropped within FRESH_MS).
65
+ * Always deletes any marker it finds so it fires at most once and never lingers.
66
+ */
67
+ export function consumePostUpdateMarker() {
68
+ let fresh = false;
69
+ for (const p of [markerPath(), legacyMarkerPath()]) {
70
+ if (!p) continue;
71
+ try {
72
+ if (!existsSync(p)) continue;
73
+ const ageMs = Date.now() - statSync(p).mtimeMs;
74
+ rmSync(p, { force: true }); // consume regardless — one-shot, no lingering marker
75
+ // Fresh = written recently. No lower bound: a just-written file's mtime can read a few
76
+ // ms AHEAD of Date.now() (FS/clock granularity on Windows), which is still "recent".
77
+ if (ageMs < FRESH_MS) fresh = true;
78
+ } catch {
79
+ /* best-effort */
80
+ }
81
+ }
82
+ return fresh;
83
+ }