@join-pantheon/cli 0.33.2 → 0.33.4-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.
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { createHash, timingSafeEqual } from "node:crypto";
4
4
  import { join } from "node:path";
5
5
  import { fingerprint } from "./identity.js";
6
- import { writeFileAtomic } from "./store.js";
6
+ import { pendingView, writerFor } from "./disk-writer.js";
7
7
  export class RosterUnreadable extends Error {
8
8
  file;
9
9
  constructor(file, detail) {
@@ -18,6 +18,8 @@ function hashToken(token) {
18
18
  return createHash("sha256").update(token).digest("hex");
19
19
  }
20
20
  export class Allowlist {
21
+ dir;
22
+ stem;
21
23
  entries = new Map();
22
24
  invites = new Map();
23
25
  tokenHash = null;
@@ -31,12 +33,15 @@ export class Allowlist {
31
33
  repos = new Map();
32
34
  recoveries = new Map();
33
35
  file;
34
- constructor(dir, space) {
35
- this.file = join(dir, `${space}.members.json`);
36
- if (existsSync(this.file)) {
36
+ constructor(dir, stem) {
37
+ this.dir = dir;
38
+ this.stem = stem;
39
+ this.file = join(dir, `${stem}.members.json`);
40
+ const pending = pendingView(dir, stem, this.file);
41
+ if (pending !== null && (pending !== undefined || existsSync(this.file))) {
37
42
  let raw;
38
43
  try {
39
- raw = JSON.parse(readFileSync(this.file, "utf8"));
44
+ raw = JSON.parse(pending ? pending.toString("utf8") : readFileSync(this.file, "utf8"));
40
45
  }
41
46
  catch (e) {
42
47
  throw new RosterUnreadable(this.file, e.message);
@@ -118,7 +123,7 @@ export class Allowlist {
118
123
  }
119
124
  founderGuess() { return this.founderGuessed; }
120
125
  flush() {
121
- writeFileAtomic(this.file, JSON.stringify({
126
+ writerFor(this.dir, this.stem).replace(this.file, JSON.stringify({
122
127
  ...this.extra,
123
128
  ...(this.id ? { id: this.id } : {}),
124
129
  ...(this.founderName ? { founder: this.founderName } : {}),
package/dist/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync, mkdirSync, renameSync, openSync, writeSync, fsyncSync, closeSync, chmodSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { emptyRefusals } from "./admin-stats.js";
4
+ import { pendingView, writerFor } from "./disk-writer.js";
4
5
  export function ensurePrivateDir(dir) {
5
6
  mkdirSync(dir, { recursive: true, mode: 0o700 });
6
7
  try {
@@ -13,6 +14,8 @@ let slowestWaitMs = 0;
13
14
  export function setWaitForDisk(on) { waitForDisk = on; }
14
15
  export function waitsForDisk() { return waitForDisk; }
15
16
  export function takeSlowestDiskWait() { const w = slowestWaitMs; slowestWaitMs = 0; return w; }
17
+ export function noteDiskWait(ms) { if (ms > slowestWaitMs)
18
+ slowestWaitMs = ms; }
16
19
  export function syncFd(fd) {
17
20
  if (!waitForDisk)
18
21
  return;
@@ -22,8 +25,21 @@ export function syncFd(fd) {
22
25
  if (took > slowestWaitMs)
23
26
  slowestWaitMs = took;
24
27
  }
28
+ export function slowDiskMs() {
29
+ const v = process.env.PANTHEON_RELAY_SLOW_DISK_MS ?? "";
30
+ const range = /^(\d+)-(\d+)$/.exec(v);
31
+ if (range) {
32
+ const a = Number(range[1]), b = Number(range[2]);
33
+ return a + Math.random() * Math.max(0, b - a);
34
+ }
35
+ const n = Number(v);
36
+ return Number.isFinite(n) && n > 0 ? Math.random() * n : 0;
37
+ }
25
38
  export function writeFileAtomic(file, data, mode = 0o600) {
26
39
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
40
+ const slow = slowDiskMs();
41
+ if (slow)
42
+ sleepSync(slow);
27
43
  const tmp = `${file}.tmp`;
28
44
  const fd = openSync(tmp, "w", mode);
29
45
  try {
@@ -36,7 +52,7 @@ export function writeFileAtomic(file, data, mode = 0o600) {
36
52
  renameRetrying(tmp, file);
37
53
  fsyncDir(dirname(file));
38
54
  }
39
- const sleepSync = (ms) => { try {
55
+ export const sleepSync = (ms) => { try {
40
56
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
41
57
  }
42
58
  catch { } };
@@ -73,6 +89,8 @@ export function fsyncDir(dir) {
73
89
  }
74
90
  }
75
91
  export class StateStore {
92
+ dir;
93
+ stem;
76
94
  file;
77
95
  timer = null;
78
96
  dirty = false;
@@ -80,18 +98,21 @@ export class StateStore {
80
98
  headOf;
81
99
  rebuilt = null;
82
100
  extra = {};
83
- constructor(dir, space) {
84
- this.file = join(dir, `${space}.state.json`);
101
+ constructor(dir, stem) {
102
+ this.dir = dir;
103
+ this.stem = stem;
104
+ this.file = join(dir, `${stem}.state.json`);
85
105
  }
86
106
  load(opts = {}) {
87
107
  const goals = new Map();
88
108
  const obligations = new Map();
89
109
  const profiles = new Map();
90
- if (!existsSync(this.file)) {
110
+ const pending = pendingView(this.dir, this.stem, this.file);
111
+ if (pending === null || (pending === undefined && !existsSync(this.file))) {
91
112
  return { goals, obligations, profiles, unclean: opts.expectHistory ? "the state snapshot is missing but the ledger has history" : null };
92
113
  }
93
114
  try {
94
- const snap = JSON.parse(readFileSync(this.file, "utf8"));
115
+ const snap = JSON.parse(pending ? pending.toString("utf8") : readFileSync(this.file, "utf8"));
95
116
  if (!snap || typeof snap !== "object" || Array.isArray(snap)
96
117
  || (snap.goals !== undefined && !Array.isArray(snap.goals))
97
118
  || (snap.obligations !== undefined && !Array.isArray(snap.obligations))) {
@@ -120,7 +141,7 @@ export class StateStore {
120
141
  catch (e) {
121
142
  const why = `the state snapshot could not be read (${e.message})`;
122
143
  let quarantined;
123
- if (opts.quarantine) {
144
+ if (opts.quarantine && !pending) {
124
145
  const aside = `${this.file}.corrupt-${Date.now()}`;
125
146
  try {
126
147
  renameSync(this.file, aside);
@@ -162,7 +183,7 @@ export class StateStore {
162
183
  ...(this.rebuilt ? { rebuilt: this.rebuilt } : {}),
163
184
  saved_at: Date.now(),
164
185
  };
165
- writeFileAtomic(this.file, JSON.stringify(snap, null, 2) + "\n");
186
+ writerFor(this.dir, this.stem).replace(this.file, JSON.stringify(snap, null, 2) + "\n");
166
187
  }
167
188
  close(goals, obligations, profiles) {
168
189
  if (this.timer) {
@@ -0,0 +1,58 @@
1
+ export const TEAM_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
2
+ export const RELAY_TEAM_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
3
+ export const PLACEHOLDER_REFUSAL = "that looks like a placeholder from an example — pick your team's real name";
4
+ export function looksLikePlaceholder(raw) {
5
+ const s = raw.trim();
6
+ if (!s)
7
+ return false;
8
+ if (/[<>{}[\]]/.test(s))
9
+ return true;
10
+ if (/^\$\{?\w+\}?$/.test(s) || /^%\w+%$/.test(s))
11
+ return true;
12
+ if (/\.\.\.|…/.test(s))
13
+ return true;
14
+ const n = s.toLowerCase().replace(/[\s_.-]+/g, "");
15
+ return /^(your|the|my|a|some|new)?(team|space|org|organi[sz]ation|workspace|group)(name|slug|id)(here)?$/.test(n)
16
+ || /^(your)(own)?(team|space|org|organi[sz]ation|workspace|group)(here)?$/.test(n)
17
+ || /^(insert|put|enter|type|add)(your|the|a)?(team|space)(name)?(here)?$/.test(n)
18
+ || /^(example|sample|placeholder|changeme|replaceme|tbd|todo|x{3,8}|name|nameofyourteam|nameoftheteam|teamhere|namehere)$/.test(n)
19
+ || /^(example|sample|placeholder)(team|space)(name)?$/.test(n);
20
+ }
21
+ export function teamSlug(raw) {
22
+ const s = raw.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "")
23
+ .replace(/[^a-z0-9_-]+/g, "-")
24
+ .replace(/-{2,}/g, "-")
25
+ .replace(/^[-_]+/, "")
26
+ .slice(0, 64)
27
+ .replace(/[-_]+$/, "");
28
+ return TEAM_NAME.test(s) ? s : "";
29
+ }
30
+ export function teamNameProblem(name, opts = {}) {
31
+ if (looksLikePlaceholder(name))
32
+ return PLACEHOLDER_REFUSAL;
33
+ if ((opts.relay ? RELAY_TEAM_NAME : TEAM_NAME).test(name))
34
+ return null;
35
+ const slug = teamSlug(name);
36
+ return `"${name}" cannot be a team name: use 1–64 letters, digits, - or _, starting with a letter or digit (it goes into invite links)` +
37
+ (slug ? ` — for example "${slug}"` : "");
38
+ }
39
+ export function shellArg(name, platform = process.platform) {
40
+ if (TEAM_NAME.test(name))
41
+ return name;
42
+ const dq = anyShellQuoted(name);
43
+ if (dq)
44
+ return dq;
45
+ return platform === "win32" ? psQuoted(name) : posixQuoted(name);
46
+ }
47
+ export function anyShellQuoted(name) {
48
+ return /[$`"\\!%]/.test(name) ? null : `"${name}"`;
49
+ }
50
+ export const posixQuoted = (name) => `'${name.replace(/'/g, `'\\''`)}'`;
51
+ export const psQuoted = (name) => `'${name.replace(/'/g, "''")}'`;
52
+ export const CMD_SPECIAL = /[&%^|<>]/;
53
+ export function relayShellArg(name) {
54
+ if (TEAM_NAME.test(name))
55
+ return name;
56
+ return anyShellQuoted(name) ?? `${posixQuoted(name)} (in PowerShell: ${psQuoted(name)})`;
57
+ }
58
+ //# sourceMappingURL=team-name.js.map
package/dist/tray.js CHANGED
@@ -27,6 +27,11 @@ export function ensureTrayBinary(home = CONFIG_DIR, deps = {}) {
27
27
  mkdirSync(dirname(binary), { recursive: true });
28
28
  return installHelper({ name: TRAY_PREBUILT, source, binary, what: "the menu bar", service: "menubar" }, deps).binary;
29
29
  }
30
+ export function menubarRefusal(f, here) {
31
+ if (Object.keys(f?.spaces ?? {}).length || here?.space)
32
+ return null;
33
+ return "this machine is not on a team yet — pantheon start (create one) or pantheon join <invite> (join one)";
34
+ }
30
35
  export function trayStale(home = CONFIG_DIR) { return helperStale(trayBinaryPath(home), traySourcePath()); }
31
36
  export function writeTrayCommands(home = CONFIG_DIR) {
32
37
  const bin = whichPantheon() ?? "pantheon";
package/dist/worker.js CHANGED
@@ -31,6 +31,7 @@ import { diagnoseRun, gitProbe, workNeedsCommands } from "./run-failure.js";
31
31
  import { installedTools, preferredTool } from "./notifier.js";
32
32
  import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
33
33
  import { CONFIG_PATH, loadAll, repoInfo, updateMembershipToken } from "./config.js";
34
+ import { shellArg } from "./team-name.js";
34
35
  import { repoName, routeRequest, safeName } from "./repo-route.js";
35
36
  import { holdsRepo } from "./repo-find.js";
36
37
  import { FOLDER_SNAPSHOT_LIMITS, keepDrafts, removeSnapshot, shown, snapshotDrafts, snapshotRoot, sweepSnapshots, takeSnapshot } from "./folder-snapshot.js";
@@ -1412,14 +1413,15 @@ export class Worker {
1412
1413
  this.keyChangeSaid.add(k);
1413
1414
  this.markDone(k);
1414
1415
  const mine = e.member === this.cfg.member;
1415
- this.opts.log(`! ${mine ? "someone asked to move YOUR seat" : `${e.member} asked to move their seat`} in ${this.cfg.space} to a new device ("${r.label}", key ${r.fp}) — ${mine ? `not you? pantheon recover cancel ${e.member}` : `check it with them, then pantheon recover confirm ${e.member} (or cancel)`}`);
1416
+ const spaceArg = shellArg(this.cfg.space);
1417
+ this.opts.log(`! ${mine ? "someone asked to move YOUR seat" : `${e.member} asked to move their seat`} in ${this.cfg.space} to a new device ("${r.label}", key ${r.fp}) — ${mine ? `not you? pantheon recover cancel ${e.member} --space ${spaceArg}` : `check it with them, then pantheon recover confirm ${e.member} --space ${spaceArg} (or cancel)`}`);
1416
1418
  try {
1417
1419
  this.opts.notify?.({
1418
1420
  kind: "worker", who: "Your worker", context: this.cfg.space, ttl: 0, sound: true,
1419
1421
  heading: mine ? "Someone asked to move your seat" : `${e.member} asked to move their seat`,
1420
1422
  body: mine
1421
- ? `A device signed in to your email account ("${r.label}", key ${r.fp}) asked for your seat in ${this.cfg.space}. It moves ${new Date(r.due_at).toLocaleString()} unless cancelled. Not you? In a terminal: pantheon recover cancel ${e.member} — and secure your email.`
1422
- : `${e.member} signed in on a new device ("${r.label}", key ${r.fp}). Check the key with them on a call, then in a terminal: pantheon recover confirm ${e.member}. Not them? pantheon recover cancel ${e.member}.`,
1423
+ ? `A device signed in to your email account ("${r.label}", key ${r.fp}) asked for your seat in ${this.cfg.space}. It moves ${new Date(r.due_at).toLocaleString()} unless cancelled. Not you? In a terminal: pantheon recover cancel ${e.member} --space ${spaceArg} — and secure your email.`
1424
+ : `${e.member} signed in on a new device ("${r.label}", key ${r.fp}). Check the key with them on a call, then in a terminal: pantheon recover confirm ${e.member} --space ${spaceArg}. Not them? pantheon recover cancel ${e.member} --space ${spaceArg}.`,
1423
1425
  });
1424
1426
  }
1425
1427
  catch { }
@@ -48,6 +48,8 @@ struct Notice: Decodable {
48
48
  let primary: Primary?
49
49
  let details: Details?
50
50
  let fix: Fix?
51
+ /** the team it came from (0.33.4): written into every action, so the CLI answers in that team with no default picked */
52
+ let space: String?
51
53
  /** where to write the chosen action and which .command files to open — supplied by the CLI */
52
54
  let bin_dir: String
53
55
  /** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
@@ -135,21 +137,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
135
137
  } else { NSApp.terminate(nil) }
136
138
  return
137
139
  }
138
- act(["action": "open", "tool": toolId, "prompt": p.prompt, "decision": notice.details?.decision as Any, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-explain.command")
140
+ act(["action": "open", "tool": toolId, "prompt": p.prompt, "decision": notice.details?.decision as Any, "act": notice.details?.act as Any, "space": notice.space as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-explain.command")
139
141
  }
140
142
 
141
143
  @objc func openTool(_ sender: NSMenuItem) { open((sender.representedObject as? String) ?? notice.primary?.default ?? "") }
142
144
  @objc func openDefault(_ sender: Any?) { open(notice.primary?.default ?? "") }
143
145
 
144
146
  @objc func showDetails(_ sender: Any?) {
145
- act(["action": "details", "act": notice.details?.act as Any, "decision": notice.details?.decision as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-details.command")
147
+ act(["action": "details", "act": notice.details?.act as Any, "decision": notice.details?.decision as Any, "space": notice.space as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-details.command")
146
148
  }
147
149
 
148
150
  /// Done: write the action, run the CLI without a Terminal, close. The CLI answers the decision as handled.
149
151
  @objc func markDone(_ sender: Any?) {
150
152
  guard !notice.bin_dir.isEmpty, let decision = notice.details?.decision else { NSApp.terminate(nil); return }
151
153
  let dir = URL(fileURLWithPath: notice.bin_dir)
152
- let payload: [String: Any] = ["action": "done", "decision": decision, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }
154
+ let payload: [String: Any] = ["action": "done", "decision": decision, "act": notice.details?.act as Any, "space": notice.space as Any].compactMapValues { $0 is NSNull ? nil : $0 }
153
155
  if let data = try? JSONSerialization.data(withJSONObject: payload) {
154
156
  try? data.write(to: dir.appendingPathComponent(".hud-action.json"), options: .atomic)
155
157
  }
@@ -0,0 +1,29 @@
1
+ {
2
+ "format": 1,
3
+ "built_at": "2026-10-07T10:18:03.223Z",
4
+ "swiftc": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)",
5
+ "helpers": {
6
+ "pantheon-tray": {
7
+ "source": "menubar.swift",
8
+ "source_sha256": "f079629099a81f9b679ef4133d897ceb09025f675099c5f941951604df3531fa",
9
+ "sha256": "cc081120555f528c83b38951663d37e8394e3a3e119a19b0bb3b2a737f4d617f",
10
+ "archs": [
11
+ "arm64",
12
+ "x86_64"
13
+ ],
14
+ "target": "macos11",
15
+ "version": "2"
16
+ },
17
+ "pantheon-hud": {
18
+ "source": "main.swift",
19
+ "source_sha256": "ffda2f8dbec3144ac902f952df1048affce6ec0e6f1b2c65ff87643a484c55c6",
20
+ "sha256": "3eb87eddbfba8c7d149b10ee34921e336d5670a5fa89243758184e5204264ccb",
21
+ "archs": [
22
+ "arm64",
23
+ "x86_64"
24
+ ],
25
+ "target": "macos11",
26
+ "version": "4"
27
+ }
28
+ }
29
+ }
@@ -601,6 +601,14 @@ function Install-Pantheon {
601
601
  return @{ code = $p.ExitCode; out = $out.Result; err = $err.Result }
602
602
  }
603
603
 
604
+ # Exit 3 from pantheon: it ended having said what happens next, and nothing
605
+ # failed -- a seat move that waits for a teammate to confirm it (recover, or
606
+ # a join of a team this person is already in). Not a failed install, and its
607
+ # words stay the last ones: no "Next" line after them.
608
+ function Resolve-PantheonSaid([hashtable]$St) {
609
+ if ($St.code -eq 3) { $St.code = 0; $St.said = $true }
610
+ }
611
+
604
612
  # Hand over to pantheon with the person's own arguments (join <invite>,
605
613
  # start): after an install, and also when the update was put off because a
606
614
  # teammate's run was in progress -- a join or a start needs a working
@@ -623,6 +631,7 @@ function Install-Pantheon {
623
631
  $null = $p.Handle # without this, ExitCode is empty once the process is gone
624
632
  $p.WaitForExit()
625
633
  $State.code = $p.ExitCode
634
+ Resolve-PantheonSaid $State
626
635
  return
627
636
  }
628
637
 
@@ -644,6 +653,7 @@ function Install-Pantheon {
644
653
  $null = $p.Handle
645
654
  $p.WaitForExit()
646
655
  $State.code = $p.ExitCode
656
+ Resolve-PantheonSaid $State
647
657
  $said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
648
658
  if ($said) { [Console]::Error.Write($said) }
649
659
  if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
@@ -1152,7 +1162,7 @@ function Install-Pantheon {
1152
1162
  }
1153
1163
  Say ''
1154
1164
  Invoke-PantheonHandoff $nodeExe $entry $ca $Arguments
1155
- if ($State.code -eq 0) {
1165
+ if ($State.code -eq 0 -and -not $State.said) {
1156
1166
  Say ''
1157
1167
  Say "Next, in this window: $(& $nextFor @('doctor')) (checks that everything works)"
1158
1168
  & $byPathNote
@@ -1174,7 +1184,7 @@ function Install-Pantheon {
1174
1184
  try {
1175
1185
  Say ''
1176
1186
  Invoke-PantheonHandoff $nodeExe $entry $caNow $Arguments
1177
- if ($State.code -eq 0 -and $nextFor) {
1187
+ if ($State.code -eq 0 -and -not $State.said -and $nextFor) {
1178
1188
  Say ''
1179
1189
  Say "Next, in this window: $(& $nextFor @('doctor')) (checks that everything works)"
1180
1190
  & $byPathNote
@@ -714,6 +714,11 @@ export PATH
714
714
  # pantheon (a trap is reset in a child, where an ignored signal would not be)
715
715
  # and this shell waits for it rather than dying first.
716
716
  finish() {
717
+ # 3: pantheon ended having said what happens next, and nothing failed -- a
718
+ # seat move that waits for a teammate to confirm it (recover, or a join of a
719
+ # team this person is already in). Its words stay the last ones, and the
720
+ # install is not reported as failed.
721
+ if [ "$1" -eq 3 ]; then exit 0; fi
717
722
  if [ "$1" -eq 0 ]; then
718
723
  say ""
719
724
  say "Next, in this terminal: $(next_line doctor) (checks that everything works)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.33.2",
3
+ "version": "0.33.4-rc.1",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {