@join-pantheon/cli 0.32.1 → 0.33.0-rc.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.
@@ -0,0 +1,323 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve, win32 } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { CONFIG_DIR } from "./config.js";
8
+ import { userEntry } from "./install.js";
9
+ import { childNodeFlags } from "./managed.js";
10
+ import { borrowedProfile } from "./service.js";
11
+ import { CLM_GUARD, GIVE_UP_CODE, hostChildRunning, hostFiles, userSid } from "./win-host.js";
12
+ import { aumidRegistered, buildUri, handlerCommand, handlerEntry, PANTHEON_AUMID, realRegIo, registerUriHandler, uriKey, } from "./uri-handler.js";
13
+ const HERE = fileURLToPath(new URL(".", import.meta.url));
14
+ export const POWERSHELL_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
15
+ export const windowsIconPath = () => resolve(HERE, "..", "helper", "windows", "pantheon.ico");
16
+ export const windowsTrayScriptPath = () => resolve(HERE, "..", "helper", "windows", "pantheon-tray.ps1");
17
+ export const TOAST_CAPS = { heading: 80, context: 120, body: 320, who: 80, button: 24 };
18
+ export const BALLOON_CAPS = { title: 63, text: 255 };
19
+ export function toastText(s, max) {
20
+ const clean = (s ?? "")
21
+ .replace(/[\t\n\r\v\f]/g, " ")
22
+ .replace(/[\u0000-\u001f\u007f-\u009f\ufffe\uffff]/g, "")
23
+ .replace(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "")
24
+ .replace(/\s+/g, " ")
25
+ .trim();
26
+ const chars = [...clean];
27
+ return chars.length <= max ? clean : `${chars.slice(0, max - 1).join("").trimEnd()}…`;
28
+ }
29
+ export function xmlEscape(s) {
30
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
31
+ }
32
+ export function toastButtons(notice, key) {
33
+ const space = notice.space;
34
+ const d = notice.details ?? {};
35
+ const out = [];
36
+ const add = (content, uri) => { if (uri)
37
+ out.push({ content, uri }); };
38
+ if (notice.kind === "decision" && d.decision) {
39
+ add("Done", buildUri("done", { decision: d.decision, act: d.act, space }, key));
40
+ add("Explain", buildUri("explain", { decision: d.decision, act: d.act, space }, key));
41
+ }
42
+ else if (d.act) {
43
+ add("Show details", buildUri("details", { act: d.act, decision: d.decision, space }, key));
44
+ }
45
+ add("Open board", buildUri("board", {}, key));
46
+ return out;
47
+ }
48
+ export function toastTag(notice) {
49
+ const id = notice.details?.decision ?? notice.details?.act ?? "";
50
+ return /^[A-Za-z0-9_]{1,60}$/.test(id) ? id : "";
51
+ }
52
+ export function toastFields(notice, key) {
53
+ const lines = [toastText(notice.heading, TOAST_CAPS.heading) || "Pantheon"];
54
+ const context = toastText(notice.context, TOAST_CAPS.context);
55
+ const body = toastText(notice.body, TOAST_CAPS.body);
56
+ if (context)
57
+ lines.push(context);
58
+ if (body)
59
+ lines.push(body);
60
+ return {
61
+ lines,
62
+ who: toastText(notice.who, TOAST_CAPS.who),
63
+ launch: buildUri("board", {}, key),
64
+ buttons: toastButtons(notice, key).map((b) => ({ content: toastText(b.content, TOAST_CAPS.button), uri: b.uri })),
65
+ scenario: notice.ttl === 0 ? "reminder" : "",
66
+ duration: notice.ttl !== 0 && notice.ttl > 7 ? "long" : "",
67
+ silent: !notice.sound,
68
+ };
69
+ }
70
+ export function toastXmlFrom(t) {
71
+ const attrs = [
72
+ `activationType="protocol"`, `launch="${xmlEscape(t.launch)}"`,
73
+ t.scenario ? `scenario="${t.scenario}"` : t.duration ? `duration="${t.duration}"` : "",
74
+ ].filter(Boolean).join(" ");
75
+ return [
76
+ `<toast ${attrs}>`,
77
+ `<visual><binding template="ToastGeneric">`,
78
+ ...t.lines.map((l) => `<text>${xmlEscape(l)}</text>`),
79
+ ...(t.who ? [`<text placement="attribution">${xmlEscape(t.who)}</text>`] : []),
80
+ `</binding></visual>`,
81
+ ...(t.buttons.length ? [
82
+ `<actions>`,
83
+ ...t.buttons.map((b) => `<action content="${xmlEscape(b.content)}" activationType="protocol" arguments="${xmlEscape(b.uri)}"/>`),
84
+ `</actions>`,
85
+ ] : []),
86
+ t.silent ? `<audio silent="true"/>` : "",
87
+ `</toast>`,
88
+ ].filter(Boolean).join("");
89
+ }
90
+ export function toastXml(notice, key) {
91
+ return toastXmlFrom(toastFields(notice, key));
92
+ }
93
+ export const windowsNoticeScriptPath = () => resolve(HERE, "..", "helper", "windows", "pantheon-notice.ps1");
94
+ const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
95
+ const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
96
+ export function powershellPath(env = process.env) {
97
+ return win32.join(env.SystemRoot || env.windir || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
98
+ }
99
+ export function powershellArgs(script, mode) {
100
+ return ["-NoProfile", "-NonInteractive", "-Command", `${CLM_GUARD}; & ([scriptblock]::Create([IO.File]::ReadAllText(${psQuote(script)}))) -Mode ${mode}`];
101
+ }
102
+ export function balloonText(notice) {
103
+ const title = toastText(notice.heading, BALLOON_CAPS.title) || "Pantheon";
104
+ const text = toastText([notice.context, notice.body].filter(Boolean).join(" — "), BALLOON_CAPS.text) || toastText(notice.who, BALLOON_CAPS.text) || "Open Pantheon";
105
+ return { title, text };
106
+ }
107
+ const aboutOf = (notice) => `${notice.kind}${notice.details?.decision ? ` ${notice.details.decision}` : notice.details?.act ? ` ${notice.details.act}` : ""}`;
108
+ export function queuedNotice(notice, key, aumids, now = Date.now()) {
109
+ return { v: 2, at: now, about: aboutOf(notice), toast: toastFields(notice, key), tag: toastTag(notice), aumids, balloon: balloonText(notice) };
110
+ }
111
+ export function toastCall(notice, key, aumids, env = process.env, script = windowsNoticeScriptPath()) {
112
+ return {
113
+ file: powershellPath(env),
114
+ args: powershellArgs(script, "toast"),
115
+ env: { PANTHEON_TOAST_XML: b64(toastXml(notice, key)), PANTHEON_TOAST_TAG: toastTag(notice), PANTHEON_TOAST_AUMIDS: aumids.join(";") },
116
+ };
117
+ }
118
+ export function balloonCall(notice, icon, env = process.env, script = windowsNoticeScriptPath()) {
119
+ const { title, text } = balloonText(notice);
120
+ return {
121
+ file: powershellPath(env),
122
+ args: powershellArgs(script, "balloon"),
123
+ env: { PANTHEON_BALLOON_TITLE: b64(title), PANTHEON_BALLOON_TEXT: b64(text), ...(icon ? { PANTHEON_BALLOON_ICON: icon } : {}) },
124
+ };
125
+ }
126
+ export const QUEUE_STALE_MS = 30_000;
127
+ export function trayTakesNotices(home, now = Date.now()) {
128
+ if (!hostChildRunning("tray", home, now))
129
+ return false;
130
+ const dir = hostFiles(home).notices;
131
+ const skipped = skippedNotices(home);
132
+ try {
133
+ for (const n of readdirSync(dir)) {
134
+ if (!n.endsWith(".json"))
135
+ continue;
136
+ try {
137
+ const mtime = statSync(join(dir, n)).mtimeMs;
138
+ if (now - mtime <= QUEUE_STALE_MS)
139
+ continue;
140
+ const s = skipped.get(n);
141
+ if (s !== undefined && Math.abs(s - Math.floor(mtime)) <= 1)
142
+ continue;
143
+ return false;
144
+ }
145
+ catch { }
146
+ }
147
+ }
148
+ catch { }
149
+ return true;
150
+ }
151
+ export function skippedNotices(home) {
152
+ const out = new Map();
153
+ let text = "";
154
+ try {
155
+ text = readFileSync(hostFiles(home).noticesSkipped, "utf8");
156
+ }
157
+ catch {
158
+ return out;
159
+ }
160
+ for (const line of text.split(/\r?\n/)) {
161
+ const m = /^([0-9A-Za-z._-]+\.json)\t(\d{1,16})$/.exec(line.trim());
162
+ if (m)
163
+ out.set(m[1], Number(m[2]));
164
+ }
165
+ return out;
166
+ }
167
+ export function ownerOnlyAclArgs(dir, sid) {
168
+ return [dir, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)F`];
169
+ }
170
+ function realQueueDirDeps() {
171
+ const exec = (c, a) => execFileSync(c, a, { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], timeout: 15_000 });
172
+ return {
173
+ platform: process.platform,
174
+ sid: () => userSid(exec),
175
+ icacls: (args) => { exec(win32.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "icacls.exe"), args); },
176
+ };
177
+ }
178
+ export function ensureQueueDir(dir, deps = realQueueDirDeps()) {
179
+ if (existsSync(dir))
180
+ return;
181
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
182
+ if (deps.platform !== "win32")
183
+ return;
184
+ try {
185
+ const sid = deps.sid();
186
+ if (!sid)
187
+ throw new Error("could not tell which user this is (whoami), so the alert queue cannot be made private");
188
+ deps.icacls(ownerOnlyAclArgs(dir, sid));
189
+ for (const n of readdirSync(dir))
190
+ rmSync(join(dir, n), { recursive: true, force: true });
191
+ }
192
+ catch (e) {
193
+ rmSync(dir, { recursive: true, force: true });
194
+ throw e;
195
+ }
196
+ }
197
+ export function enqueueNotice(home, q, deps) {
198
+ const dir = hostFiles(home).notices;
199
+ ensureQueueDir(dir, deps);
200
+ const name = `${q.at}-${randomBytes(4).toString("hex")}`;
201
+ const tmp = join(dir, `${name}.tmp`);
202
+ const f = join(dir, `${name}.json`);
203
+ writeFileSync(tmp, JSON.stringify(q), { mode: 0o600, flag: "wx" });
204
+ try {
205
+ renameSync(tmp, f);
206
+ }
207
+ catch (e) {
208
+ rmSync(tmp, { force: true });
209
+ throw e;
210
+ }
211
+ return f;
212
+ }
213
+ export const NOTICE_COOLDOWN_MS = 5 * 60_000;
214
+ export const CLM_COOLDOWN_MS = 6 * 3600_000;
215
+ let coolUntil = 0;
216
+ let handler = null;
217
+ export function resetWindowsNoticeState() { coolUntil = 0; handler = null; }
218
+ export function ensureUriHandler(io = realRegIo(() => borrowedProfile()), entry = userEntry("win32"), o = {}) {
219
+ const command = handlerCommand(handlerEntry(entry, o.flags ?? childNodeFlags()), o.home === undefined ? nonDefaultPantheonHome() : o.home);
220
+ if (!command)
221
+ return { ok: false, aumid: false, line: "pantheon: links not registered — no node.exe and cli.js to name (a pantheon.cmd is never registered)" };
222
+ const icon = windowsIconPath();
223
+ const r = registerUriHandler(command, io, existsSync(icon) ? icon : undefined);
224
+ return { ok: r.ok, aumid: r.ok && aumidRegistered(io), line: r.line };
225
+ }
226
+ function nonDefaultPantheonHome() {
227
+ return resolve(CONFIG_DIR).toLowerCase() === resolve(join(homedir(), ".pantheon")).toLowerCase() ? null : resolve(CONFIG_DIR);
228
+ }
229
+ function logTo(home) {
230
+ return (line) => {
231
+ try {
232
+ mkdirSync(join(home, "logs"), { recursive: true });
233
+ appendFileSync(join(home, "logs", "alerts.log"), `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
234
+ }
235
+ catch { }
236
+ };
237
+ }
238
+ function realDeps(home) {
239
+ const icon = windowsIconPath();
240
+ const childEnv = (call) => ({ ...process.env, PANTHEON_HOME: home, ...call.env });
241
+ return {
242
+ trayReady: () => trayTakesNotices(home),
243
+ enqueue: (q) => { enqueueNotice(home, q); },
244
+ run: (call, timeoutMs) => {
245
+ const r = spawnSync(call.file, call.args, { env: childEnv(call), windowsHide: true, timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
246
+ const said = (r.stdout ?? "").trim().split(/\r?\n/).filter(Boolean).join(" | ");
247
+ if (said)
248
+ logTo(home)(`toast: ${said.slice(0, 300)}`);
249
+ return r.error ? null : r.status;
250
+ },
251
+ detach: (call) => {
252
+ try {
253
+ const c = spawn(call.file, call.args, { env: childEnv(call), windowsHide: true, detached: true, stdio: "ignore" });
254
+ c.on("error", () => { });
255
+ c.unref();
256
+ return typeof c.pid === "number";
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ },
262
+ ensureHandler: () => {
263
+ const r = ensureUriHandler();
264
+ logTo(home)(`links: ${r.line}`);
265
+ return { aumid: r.aumid };
266
+ },
267
+ markClm: () => { try {
268
+ const f = hostFiles(home);
269
+ mkdirSync(f.dir, { recursive: true });
270
+ writeFileSync(f.clm, new Date().toISOString());
271
+ }
272
+ catch { } },
273
+ log: logTo(home),
274
+ icon: existsSync(icon) ? icon : null,
275
+ now: Date.now,
276
+ };
277
+ }
278
+ export function showWindowsNotice(notice, home, deps = realDeps(home)) {
279
+ if (deps.now() < coolUntil)
280
+ return false;
281
+ if (!handler) {
282
+ try {
283
+ handler = deps.ensureHandler();
284
+ }
285
+ catch {
286
+ handler = { aumid: false };
287
+ }
288
+ }
289
+ const key = uriKey(home);
290
+ const aumids = handler.aumid ? [PANTHEON_AUMID, POWERSHELL_AUMID] : [POWERSHELL_AUMID];
291
+ const about = aboutOf(notice);
292
+ if (deps.trayReady()) {
293
+ try {
294
+ deps.enqueue(queuedNotice(notice, key, aumids, deps.now()));
295
+ deps.log(`handed to the tray: ${about}`);
296
+ return true;
297
+ }
298
+ catch (e) {
299
+ deps.log(`could not hand ${about} to the tray (${e.message.split("\n")[0]}) — showing it directly`);
300
+ }
301
+ }
302
+ const code = deps.run(toastCall(notice, key, aumids), 15_000);
303
+ if (code === 0) {
304
+ deps.log(`shown: ${about}`);
305
+ return true;
306
+ }
307
+ if (code === GIVE_UP_CODE) {
308
+ deps.markClm();
309
+ coolUntil = deps.now() + CLM_COOLDOWN_MS;
310
+ deps.log(`not shown: ${about} — PowerShell runs in Constrained Language Mode here (an IT policy), which refuses Windows alerts; pantheon board shows the same. Next try in ${CLM_COOLDOWN_MS / 3600_000} hours`);
311
+ return false;
312
+ }
313
+ if (code === 3 || code === 4) {
314
+ if (deps.detach(balloonCall(notice, deps.icon))) {
315
+ deps.log(`handed to a balloon: ${about} (toast exit ${code})`);
316
+ return true;
317
+ }
318
+ }
319
+ coolUntil = deps.now() + NOTICE_COOLDOWN_MS;
320
+ deps.log(`not shown: ${about} — toast exit ${code ?? "timeout or no PowerShell"}; next try in ${NOTICE_COOLDOWN_MS / 60_000} minutes`);
321
+ return false;
322
+ }
323
+ //# sourceMappingURL=notice-windows.js.map
package/dist/notifier.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { showWindowsNotice } from "./notice-windows.js";
2
+ import { showLinuxNotice } from "./notice-linux.js";
1
3
  import { spawn } from "node:child_process";
2
4
  import { helperStale, installHelper } from "./helpers-build.js";
3
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -107,6 +109,10 @@ export function preferredTool(profile, tools, home = CONFIG_DIR) {
107
109
  return profile.agent.primary_surface;
108
110
  }
109
111
  export function sendDesktopNotification(notice, home = CONFIG_DIR) {
112
+ if (platform() === "win32")
113
+ return showWindowsNotice(notice, home);
114
+ if (platform() === "linux")
115
+ return showLinuxNotice(notice, home);
110
116
  if (platform() !== "darwin")
111
117
  return false;
112
118
  const binary = hudBinaryPath(home);
@@ -154,12 +160,15 @@ export class HumanNotifier {
154
160
  : [];
155
161
  const tools = installedTools();
156
162
  const peerOf = (from) => from ? { member: from, agent: client.roster.find((m) => m.id === from)?.agent } : undefined;
157
- const withLabel = (n) => this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
163
+ const withLabel = (n) => {
164
+ const labelled = this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
165
+ return platform() === "darwin" ? labelled : { ...labelled, space: this.cfg.space };
166
+ };
158
167
  for (const obligation of due) {
159
168
  const isReminder = state.notified_at[obligation.id] !== undefined;
160
169
  const act = client.seenAct(obligation.ref);
161
170
  const notice = withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: preferredTool(profile, tools), now: now(), member: this.cfg.member }));
162
- const ok = send(notice);
171
+ const ok = await send(notice);
163
172
  if (ok) {
164
173
  state.notified_at[obligation.id] = now();
165
174
  if (isReminder)
@@ -182,7 +191,7 @@ export class HumanNotifier {
182
191
  const forMe = (r.to?.includes(this.cfg.member) ?? false) || (goal?.members.includes(this.cfg.member) ?? false);
183
192
  if (!forMe)
184
193
  continue;
185
- const ok = send(withLabel(composeDelivery(r, goal, peerOf(r.from), { tools, preferred: preferredTool(profile, tools), now: now() })));
194
+ const ok = await send(withLabel(composeDelivery(r, goal, peerOf(r.from), { tools, preferred: preferredTool(profile, tools), now: now() })));
186
195
  if (ok) {
187
196
  state.notified_at[`act:${r.id}`] = now();
188
197
  writeState(stateFile, state);
@@ -1,12 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:net";
4
- import { basename, dirname, join as pathJoin, resolve as resolvePath } from "node:path";
4
+ import { basename, join as pathJoin, resolve as resolvePath } from "node:path";
5
5
  import { homedir, hostname, platform, userInfo } from "node:os";
6
6
  import { lookup } from "node:dns/promises";
7
7
  import { describeNetError } from "./network.js";
8
8
  import { PantheonClient } from "./client.js";
9
9
  import { holdsRepo } from "./repo-find.js";
10
+ import { folderFacts, folderRefusal, folderRefusalHere, isInside, isTemporaryRefusal } from "./folder-guard.js";
10
11
  import { load, loadAll, identity, save, toInvite, resolveFor, repoInfo, bindRepo, bindFolder, folderBinding, DEFAULT_RELAY } from "./config.js";
11
12
  import { commandsChosen, loadProfile, wakeChosen, wakeDefault } from "./profile.js";
12
13
  import { newKeypair } from "./identity.js";
@@ -14,6 +15,7 @@ import { installForUser } from "./install.js";
14
15
  import { writeIntent, installHooks } from "./notify.js";
15
16
  import { detectAvailable, agentById, agentsNeedingUnsandboxed } from "./agents.js";
16
17
  import { installService, servicesUnavailable } from "./service.js";
18
+ import { clearTrayQuit } from "./win-host.js";
17
19
  import { ensureNativeNotifier } from "./notifier.js";
18
20
  import { ensureTrayBinary, writeTrayCommands } from "./tray.js";
19
21
  import { desktopUnavailable, ephemeralWarning, hostEnv } from "./host-env.js";
@@ -39,7 +41,7 @@ export function wireUp(opts, deps = {}) {
39
41
  const ensureTray = deps.ensureTray ?? ensureTrayBinary;
40
42
  const host = deps.host ?? hostEnv();
41
43
  const noServices = servicesUnavailable(host.platform);
42
- const tools = installForUser().map((t) => ({ tool: t.tool, status: t.status }));
44
+ const tools = installForUser(undefined, undefined, { direct: true }).map((t) => ({ tool: t.tool, status: t.status }));
43
45
  const granted = [];
44
46
  const warnings = [];
45
47
  if (host.ephemeral)
@@ -66,9 +68,12 @@ export function wireUp(opts, deps = {}) {
66
68
  }
67
69
  else if (opts.notifications) {
68
70
  try {
69
- ensureNotifier();
71
+ const mac = host.platform === "darwin";
72
+ if (mac)
73
+ ensureNotifier();
70
74
  const why = notLoaded(installSvc("notifier", homedir(), []));
71
- writeTrayCommands();
75
+ if (mac)
76
+ writeTrayCommands();
72
77
  if (why) {
73
78
  tools.push({ tool: "notifications", status: `installed, not running: ${why}` });
74
79
  warnings.push(`human notifications are installed but not running (${why}). Try \`pantheon service restart notifier\``);
@@ -84,8 +89,13 @@ export function wireUp(opts, deps = {}) {
84
89
  else {
85
90
  tools.push({ tool: "notifications", status: "off (--no-notifications) — human escalations only appear in pantheon status" });
86
91
  }
87
- if (opts.menubar && !host.desktop) {
88
- tools.push({ tool: "menu bar", status: desktopUnavailable(host, "the menu bar") });
92
+ const noMenubar = desktopUnavailable(host, "the menu bar");
93
+ if (opts.menubar && noMenubar) {
94
+ tools.push({ tool: "menu bar", status: noMenubar });
95
+ }
96
+ else if (opts.menubar && host.platform === "win32") {
97
+ const wasQuit = clearTrayQuit();
98
+ tools.push({ tool: "tray", status: `comes with the background host — the Pantheon icon in the notification area${wasQuit ? " (you had quit it; it shows again)" : ""}` });
89
99
  }
90
100
  else if (opts.menubar) {
91
101
  try {
@@ -491,6 +501,8 @@ export async function start(opts) {
491
501
  bindFolder(process.cwd(), c.spaceId);
492
502
  folderBound = process.cwd();
493
503
  }
504
+ else if (!repo && c.spaceId)
505
+ temporaryFolderNote(process.cwd(), opts.log, c.space);
494
506
  if (repo && c.spaceId) {
495
507
  const { replaced } = bindRepo(repo, c.spaceId);
496
508
  const was = replaced ?? (here.binding && here.binding.space !== c.spaceId ? here.binding : null);
@@ -578,6 +590,8 @@ export async function join(opts) {
578
590
  bindFolder(process.cwd(), cfg.spaceId);
579
591
  opts.log(` folder ${process.cwd()} → bound to "${cfg.space}" (agents opened here speak in it; no git needed)`);
580
592
  }
593
+ else if (!repo && cfg.spaceId)
594
+ temporaryFolderNote(process.cwd(), opts.log, cfg.space);
581
595
  if (repo && cfg.spaceId) {
582
596
  const { replaced } = bindRepo(repo, cfg.spaceId);
583
597
  if (replaced)
@@ -605,10 +619,8 @@ export function bindableFolder(dir) {
605
619
  catch {
606
620
  return resolvePath(d);
607
621
  } };
608
- const here = real(dir), home = real(homedir());
609
- if (dirname(here) === here || here === home || home.startsWith(here.endsWith("/") ? here : `${here}/`))
610
- return false;
611
- if (["Downloads", "Desktop", "Documents", "Library", "tmp"].some((n) => here === pathJoin(home, n)))
622
+ const here = real(dir);
623
+ if (folderRefusal(here, folderFacts()))
612
624
  return false;
613
625
  try {
614
626
  if (holdsRepo(here))
@@ -619,12 +631,17 @@ export function bindableFolder(dir) {
619
631
  }
620
632
  try {
621
633
  const f = loadAll();
622
- if (Object.values(f?.bindings ?? {}).some((b) => { const p = real(b.path); return p !== here && p.startsWith(`${here}/`); }))
634
+ if (Object.values(f?.bindings ?? {}).some((b) => { const p = real(b.path); return isInside(p, here) && !isInside(here, p); }))
623
635
  return false;
624
636
  }
625
637
  catch { }
626
638
  return true;
627
639
  }
640
+ function temporaryFolderNote(dir, log, team) {
641
+ const r = folderRefusalHere(dir);
642
+ if (isTemporaryRefusal(r))
643
+ log(` folder not bound — ${r.why.replace("<team>", team)}`);
644
+ }
628
645
  function detectAgent() {
629
646
  const found = detectAvailable();
630
647
  return found[0]?.backend.id ?? "unknown";
package/dist/relay.js CHANGED
@@ -6,14 +6,14 @@ import { join } from "node:path";
6
6
  import { WebSocketServer } from "ws";
7
7
  import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
8
8
  import { VERSION } from "./version.js";
9
- import { installerScript, InstallerGate } from "./installer.js";
9
+ import { compareVersions, INSTALLER_MIN_CLI, isVersion, installerScript, InstallerGate } from "./installer.js";
10
10
  import { installerLine } from "./managed.js";
11
11
  import { Ledger, SpaceFull } from "./ledger.js";
12
12
  import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead, actPayload, goalPayload, verifyBytes, verifyDelegate, canonicalJson } from "./identity.js";
13
13
  import { Accounts, normalizeUsername, validEmail, validUsername } from "./accounts.js";
14
14
  import { emailSenderFromEnv } from "./email.js";
15
15
  import { Allowlist, RosterUnreadable } from "./roster-file.js";
16
- import { StateStore, ensurePrivateDir } from "./store.js";
16
+ import { StateStore, ensurePrivateDir, setWaitForDisk, takeSlowestDiskWait, waitsForDisk } from "./store.js";
17
17
  import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
18
18
  import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
19
19
  import { FEEDBACK_ADDRESS } from "./feedback.js";
@@ -366,8 +366,35 @@ function withoutFromRepo(meta) {
366
366
  const { from_repo: _f, ...rest } = meta;
367
367
  return rest;
368
368
  }
369
+ export function upgradeHoldFrom(raw, relayVersion, log = () => { }) {
370
+ const v = (raw ?? "").trim();
371
+ if (!v)
372
+ return null;
373
+ if (!isVersion(v)) {
374
+ log(`! PANTHEON_HOLD_UPGRADES_AT is "${v}", which is not a version — ignored, upgrades are not held`);
375
+ return null;
376
+ }
377
+ if (v.includes("-")) {
378
+ log(`! PANTHEON_HOLD_UPGRADES_AT is ${v}, a prerelease — ignored; hold at a release (x.y.z)`);
379
+ return null;
380
+ }
381
+ if (compareVersions(v, relayVersion) > 0) {
382
+ log(`! PANTHEON_HOLD_UPGRADES_AT is ${v}, newer than this relay (${relayVersion}) — ignored`);
383
+ return null;
384
+ }
385
+ if (compareVersions(v, INSTALLER_MIN_CLI) < 0) {
386
+ log(`! PANTHEON_HOLD_UPGRADES_AT is ${v}, older than ${INSTALLER_MIN_CLI}, the oldest release the installer serves — ignored`);
387
+ return null;
388
+ }
389
+ log(`* upgrades held at ${v}: workers will not move past it, and new installs get it (this relay runs ${relayVersion})`);
390
+ return v;
391
+ }
392
+ export function advertisedPkg(hold, relayVersion) {
393
+ return hold ?? (relayVersion.includes("-") ? null : relayVersion);
394
+ }
369
395
  export class Relay {
370
396
  opts;
397
+ advertisedPkg() { return advertisedPkg(this.upgradeHold, VERSION); }
371
398
  spaces = new Map();
372
399
  dataDir;
373
400
  log;
@@ -410,6 +437,9 @@ export class Relay {
410
437
  });
411
438
  wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
412
439
  heartbeat;
440
+ lagTimer;
441
+ lagMax = 0;
442
+ fsyncOff = false;
413
443
  compactedAt = new Map();
414
444
  sessionSeq = 0;
415
445
  spaceCreates = new Map();
@@ -437,11 +467,17 @@ export class Relay {
437
467
  rotateOnRemove;
438
468
  backupTimer;
439
469
  installerGate;
470
+ upgradeHold;
440
471
  get features() { return this.opts.features ?? RELAY_FEATURES; }
441
472
  constructor(opts = {}) {
442
473
  this.opts = opts;
443
474
  this.dataDir = opts.dataDir ?? "./pantheon-data";
444
475
  this.log = opts.log ?? ((l) => console.error(l));
476
+ if ((opts.fsync ?? process.env.PANTHEON_RELAY_FSYNC) === "off" && waitsForDisk()) {
477
+ setWaitForDisk(false);
478
+ this.fsyncOff = true;
479
+ this.log("* PANTHEON_RELAY_FSYNC=off: writes go to the operating system at once but the relay does not wait for the disk to confirm them (a relay crash loses nothing; a host power loss can lose the last seconds)");
480
+ }
445
481
  ensurePrivateDir(this.dataDir);
446
482
  this.maxLedgerBytes = opts.spaceMaxBytes
447
483
  ?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
@@ -474,7 +510,8 @@ export class Relay {
474
510
  spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
475
511
  };
476
512
  this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
477
- this.installerGate = opts.installerGate ?? new InstallerGate({ log: this.log });
513
+ this.upgradeHold = upgradeHoldFrom(opts.upgradeHoldAt ?? process.env.PANTHEON_HOLD_UPGRADES_AT, VERSION, this.log);
514
+ this.installerGate = opts.installerGate ?? new InstallerGate({ log: this.log, ...(this.upgradeHold ? { version: this.upgradeHold } : {}) });
478
515
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
479
516
  this.pruneMs = opts.activityPruneMs ?? LIMITS.activityPruneMs;
480
517
  const readsEnv = (process.env.PANTHEON_READS ?? "").trim().toLowerCase();
@@ -495,7 +532,10 @@ export class Relay {
495
532
  const host = this.opts.host ?? "0.0.0.0";
496
533
  return new Promise((resolve) => {
497
534
  this.http.listen(want, host, () => {
498
- this.heartbeat = setInterval(() => this.sweep(), 15_000);
535
+ this.heartbeat = setInterval(() => { this.sweep(); this.noteStalls(); }, 15_000);
536
+ let last = Date.now();
537
+ this.lagTimer = setInterval(() => { const now = Date.now(); this.lagMax = Math.max(this.lagMax, now - last - 500); last = now; }, 500);
538
+ this.lagTimer.unref?.();
499
539
  this.startBackups();
500
540
  const addr = this.http.address();
501
541
  const port = typeof addr === "object" && addr ? addr.port : want;
@@ -519,6 +559,12 @@ export class Relay {
519
559
  this.accounts.close();
520
560
  if (this.heartbeat)
521
561
  clearInterval(this.heartbeat);
562
+ if (this.lagTimer)
563
+ clearInterval(this.lagTimer);
564
+ if (this.fsyncOff) {
565
+ setWaitForDisk(true);
566
+ this.fsyncOff = false;
567
+ }
522
568
  for (const ws of this.wss.clients)
523
569
  ws.terminate();
524
570
  await new Promise((r) => this.wss.close(() => r()));
@@ -591,7 +637,9 @@ export class Relay {
591
637
  ok: !sf,
592
638
  ...(sf ? { storage: { error: sf.message, where: sf.where, at: new Date(sf.at).toISOString() } } : {}),
593
639
  v: PROTOCOL_VERSION,
594
- pkg: VERSION,
640
+ ...(this.advertisedPkg() ? { pkg: this.advertisedPkg() } : {}),
641
+ ...(this.advertisedPkg() !== VERSION ? { relay_pkg: VERSION } : {}),
642
+ ...(this.upgradeHold ? { upgrades_held_at: this.upgradeHold } : {}),
595
643
  ...(commit ? { commit } : {}),
596
644
  ...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
597
645
  space_count: this.spaces.size,
@@ -781,7 +829,7 @@ export class Relay {
781
829
  if (url.searchParams.get("format") === "json")
782
830
  return this.json(res, 200, { relay: VERSION, relay_age_ms: relayAgeMs, generated: Date.now(), spaces: rows });
783
831
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
784
- return void res.end(fleetReport(rows, VERSION, Date.now(), relayAgeMs) + "\n");
832
+ return void res.end(fleetReport(rows, this.upgradeHold ?? VERSION, Date.now(), relayAgeMs) + "\n");
785
833
  }
786
834
  if (url.pathname === "/admin/export") {
787
835
  if (!authed)
@@ -1288,7 +1336,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1288
1336
  out.push(spaceHealth({
1289
1337
  name: sp.name, records: sp.ledger.all(), members: sp.allow.all(),
1290
1338
  online: sessions.length, interactive: sessions.filter((x) => x.role === "interactive").length,
1291
- relayPkg: VERSION, now, relayAgeMs: now - this.startedAt,
1339
+ relayPkg: this.upgradeHold ?? VERSION, now, relayAgeMs: now - this.startedAt,
1292
1340
  }));
1293
1341
  }
1294
1342
  let files = [];
@@ -1307,7 +1355,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1307
1355
  members = new Allowlist(this.dataDir, safe).all();
1308
1356
  }
1309
1357
  catch { }
1310
- out.push(spaceHealth({ name: safe, records: this.sleepingRecords(safe), members, online: 0, interactive: 0, relayPkg: VERSION, now, relayAgeMs: now - this.startedAt }));
1358
+ out.push(spaceHealth({ name: safe, records: this.sleepingRecords(safe), members, online: 0, interactive: 0, relayPkg: this.upgradeHold ?? VERSION, now, relayAgeMs: now - this.startedAt }));
1311
1359
  }
1312
1360
  return out;
1313
1361
  }
@@ -1951,7 +1999,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1951
1999
  ${ADMIN_CSS}
1952
2000
  <header>
1953
2001
  <h1>pantheon · admin</h1>
1954
- <span class=meta>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
2002
+ <span class=meta>${esc(this.opts.host ?? "relay")} · pkg ${VERSION}${this.upgradeHold ? ` · upgrades held at ${esc(this.upgradeHold)}` : ""} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
1955
2003
  <span class=links><a href="/admin/export?table=audit&format=csv">export audit CSV</a><a href="/admin/export?table=accounts&format=json">accounts JSON</a><a href="/admin/backup">download backup</a><a href="/admin/logout">sign out</a></span>
1956
2004
  </header>
1957
2005
  <nav class=jump aria-label="Sections"><a href="#s-activity">Activity</a><a href="#s-spaces">Spaces</a><a href="#s-accounts">Accounts</a><a href="#s-plans">Plans</a><a href="#s-sessions">Sessions</a><a href="#s-blocks">Blocked</a><a href="#s-codes">Codes</a><a href="#s-audit">Audit log</a></nav>
@@ -3244,6 +3292,13 @@ pantheon start</code></pre>
3244
3292
  } });
3245
3293
  ws.on("error", () => { });
3246
3294
  }
3295
+ noteStalls() {
3296
+ const worst = this.lagMax;
3297
+ this.lagMax = 0;
3298
+ const disk = takeSlowestDiskWait();
3299
+ if (worst >= 1000)
3300
+ this.log(`! the relay could not answer for up to ${(worst / 1000).toFixed(1)} s in the last 15 s${disk ? ` (longest disk wait ${(disk / 1000).toFixed(1)} s)` : ""}`);
3301
+ }
3247
3302
  sweep() {
3248
3303
  for (const ws of this.wss.clients) {
3249
3304
  const s = ws;