@join-pantheon/cli 0.25.0 → 0.25.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/README.md CHANGED
@@ -96,6 +96,10 @@ any other server. The install output prints that exact line, the defaults screen
96
96
  your agent sends in your name, `pantheon setup --ask-before-sending` (or the same flag on start/join)
97
97
  makes reads silent and prompts for send, open goal and claim; `--approve-all` switches back.
98
98
 
99
+ If all you were sent is the code — `pantheon join iIF-uHY8…` with no team or link — that works
100
+ too: the relay names the team the code opens, and the join proceeds. A spent or expired code says
101
+ so and asks for a fresh invite; a relay that cannot be reached asks you for the whole line instead.
102
+
99
103
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
100
104
  `pantheon install --user` (from your project's repo if you have one).
101
105
 
@@ -125,6 +125,15 @@ export function versionBehind(pkg, relay) {
125
125
  }
126
126
  return false;
127
127
  }
128
+ export const SELF_UPGRADE_SINCE = "0.24.0";
129
+ export function stuckBehind(pkg, relay) {
130
+ if (!versionBehind(pkg, relay))
131
+ return false;
132
+ if (versionBehind(pkg, SELF_UPGRADE_SINCE))
133
+ return true;
134
+ const [aMaj = 0, aMin = 0] = pkg.split(".").map(Number), [bMaj = 0, bMin = 0] = relay.split(".").map(Number);
135
+ return aMaj < bMaj || bMin - aMin >= 2;
136
+ }
128
137
  const daysAgo = (ts, now) => Math.max(0, Math.floor((now - ts) / DAY));
129
138
  export function spaceHealth(input) {
130
139
  const now = input.now ?? Date.now();
@@ -153,10 +162,10 @@ export function spaceHealth(input) {
153
162
  if (!m.last_pkg)
154
163
  continue;
155
164
  versions[m.last_pkg] = (versions[m.last_pkg] ?? 0) + 1;
156
- if (versionBehind(m.last_pkg, input.relayPkg))
165
+ if (stuckBehind(m.last_pkg, input.relayPkg))
157
166
  behind.push({ member: m.member, pkg: m.last_pkg, days: daysAgo(seenAt(m), now) });
158
167
  }
159
- behind.sort((a, b) => a.days - b.days);
168
+ behind.sort((a, b) => (versionBehind(a.pkg, b.pkg) ? -1 : versionBehind(b.pkg, a.pkg) ? 1 : a.days - b.days));
160
169
  const newest = admitted.filter((m) => m.last_pkg).sort((a, b) => seenAt(b) - seenAt(a))[0];
161
170
  const seenSays = last_seen === null ? "" : `; last seen ${daysAgo(last_seen, now)}d ago${newest ? ` on ${newest.last_pkg}` : ""}`;
162
171
  let verdict, says;
package/dist/cli.js CHANGED
@@ -10,10 +10,10 @@ import { randomBytes } from "node:crypto";
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { basename, dirname, join, resolve } from "node:path";
12
12
  import { PantheonClient } from "./client.js";
13
- import { fromInvite, load, save, toInvite, toJoinLink, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
13
+ import { fromInvite, looksLikeInviteCode, resolveInviteCode, load, save, toInvite, toJoinLink, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
14
14
  import { fmtLimit } from "./plans.js";
15
15
  import { validEmail, validUsername } from "./accounts.js";
16
- import { install, launcher, removeCodexUserScope } from "./install.js";
16
+ import { install, launcher, installForUser, removeCodexUserScope } from "./install.js";
17
17
  import { Relay } from "./relay.js";
18
18
  import { runMcp } from "./mcp.js";
19
19
  import { Worker } from "./worker.js";
@@ -728,7 +728,7 @@ async function main() {
728
728
  return;
729
729
  }
730
730
  case "join": {
731
- const invite = argv[1] ?? die("usage: pantheon join <invite> (pantheons://host/space#token, or https://www.joinpantheon.network/join/<team>#<token>) [setup answers as flags — pantheon setup --questions]");
731
+ const invite = argv[1] ?? die("usage: pantheon join <invite> (pantheons://host/space#token, https://www.joinpantheon.network/join/<team>#<token>, or just the code) [setup answers as flags — pantheon setup --questions]");
732
732
  const setupFlags = parseSetupFlags(argv);
733
733
  if (setupFlags.errors.length)
734
734
  die(setupFlags.errors.join("\n") + "\n pantheon setup --questions lists every answer");
@@ -737,7 +737,11 @@ async function main() {
737
737
  parsed = fromInvite(invite);
738
738
  }
739
739
  catch (e) {
740
- die(e.message);
740
+ if (!looksLikeInviteCode(invite))
741
+ die(e.message);
742
+ const candidates = [flag("relay"), process.env.PANTHEON_RELAY, load()?.relay, DEFAULT_RELAY].filter((r) => !!r);
743
+ parsed = await resolveInviteCode(invite, candidates).catch((err) => die(err.message.replace(/^INVITE_CODE_[A-Z]+:\s*/, "")));
744
+ console.error(D(`That was just the code — it opens "${parsed.space}" on ${new URL(parsed.relay).host}. Joining that team.\n`));
741
745
  }
742
746
  if (!load()?.account && sameHost(parsed.relay, DEFAULT_RELAY)) {
743
747
  die(`sign in first — this relay verifies email.\n New here: pantheon signup --email you@work.com --username you\n Have an account: pantheon login --email you@work.com\n Then run this same \`pantheon join\` command again — the invite is still good.`);
@@ -811,6 +815,16 @@ async function main() {
811
815
  console.error(`\n${B("Restart your agent")}, then ask it: "check pantheon_status and introduce yourself to the team".`);
812
816
  return;
813
817
  }
818
+ case "rewire": {
819
+ const rows = installForUser().map((t) => ({ tool: t.tool, status: t.status }));
820
+ if (argv.includes("--json")) {
821
+ console.log(JSON.stringify(rows));
822
+ return;
823
+ }
824
+ for (const r of rows)
825
+ console.error(` ${r.tool.padEnd(14)}${r.status}`);
826
+ return;
827
+ }
814
828
  case "upgrade": {
815
829
  const { upgrade } = await import("./upgrade.js");
816
830
  console.error(`${B("pantheon upgrade")}\n`);
package/dist/config.js CHANGED
@@ -305,4 +305,32 @@ export function fromInvite(invite) {
305
305
  token,
306
306
  };
307
307
  }
308
+ export const INVITE_CODE = /^[A-Za-z0-9_-]{16,64}$/;
309
+ export const looksLikeInviteCode = (s) => INVITE_CODE.test(s.trim().replace(/^['"`]|['"`]$/g, ""));
310
+ export async function resolveInviteCode(code, relays, fetchImpl = fetch) {
311
+ const token = code.trim().replace(/^['"`]|['"`]$/g, "");
312
+ const tried = [...new Set(relays.map((r) => r.replace(/\/+$/, "")).filter(Boolean))];
313
+ let answered = false;
314
+ for (const relay of tried) {
315
+ const http = relay.replace(/^ws/, "http");
316
+ try {
317
+ const res = await fetchImpl(`${http}/join?token=${encodeURIComponent(token)}`, { signal: AbortSignal.timeout(6_000) });
318
+ if (res.status === 404) {
319
+ answered = true;
320
+ continue;
321
+ }
322
+ if (!res.ok)
323
+ continue;
324
+ const body = (await res.json());
325
+ if (typeof body.team === "string" && body.team)
326
+ return { relay, space: body.team, token };
327
+ }
328
+ catch { }
329
+ }
330
+ const hosts = tried.map((r) => new URL(r).host).join(", ");
331
+ if (answered) {
332
+ throw new Error(`INVITE_CODE_UNKNOWN: that is an invite code, but no open invite on ${hosts} matches it — it may have been used already or expired (they last 24 hours).\n Ask your teammate for a fresh one: pantheon invite`);
333
+ }
334
+ throw new Error(`INVITE_CODE_UNREACHABLE: that looks like an invite code, but ${hosts} could not be asked which team it opens.\n Paste the whole invite line instead: pantheon join https://www.joinpantheon.network/join/<team>#${token}`);
335
+ }
308
336
  //# sourceMappingURL=config.js.map
package/dist/doctor.js CHANGED
@@ -12,7 +12,7 @@ import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
12
12
  import { approvalSummary, chosenApproval, onPath } from "./install.js";
13
13
  import { hookState, readIntent, agentHookStates } from "./notify.js";
14
14
  import { loadPersonas } from "./persona.js";
15
- import { workerProc } from "./machine.js";
15
+ import { workerProc, menubarProc, notifierProc } from "./machine.js";
16
16
  import { VERSION } from "./version.js";
17
17
  import { serviceInstalled } from "./service.js";
18
18
  import { hostEnv } from "./host-env.js";
@@ -213,9 +213,24 @@ export async function doctor(projectDir) {
213
213
  }
214
214
  }
215
215
  if (host.desktop) {
216
- serviceInstalled("menubar")
217
- ? add("pass", "menu bar", "installed — status, pause/resume, and a badge when a decision needs you", undefined, "installed")
218
- : add("warn", "menu bar", "not installed on a Mac that can run it", "install it with: pantheon service install menubar (needs the Xcode Command Line Tools — xcode-select --install — and a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice", "not installed");
216
+ if (!serviceInstalled("menubar")) {
217
+ add("warn", "menu bar", "not installed on a Mac that can run it", "install it with: pantheon service install menubar (needs the Xcode Command Line Tools — xcode-select --install — and a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice", "not installed");
218
+ }
219
+ else if (!menubarProc().running) {
220
+ add("warn", "menu bar", "installed, but not running — launchd is not holding it, so there is no icon right now", "pantheon service restart menubar (loads it back; something unloaded it — a stop, a crash loop, or a test run on this machine)", "installed, not running");
221
+ }
222
+ else {
223
+ add("pass", "menu bar", "installed and running — status, pause/resume, and a badge when a decision needs you", undefined, "installed and running");
224
+ }
225
+ if (!serviceInstalled("notifier")) {
226
+ add("warn", "alerts", "not installed — no desktop notification when a teammate needs you", "pantheon service install notifier (--no-notifications at setup also leaves it off; that is fine if it was your choice)", "not installed");
227
+ }
228
+ else if (!notifierProc().running) {
229
+ add("warn", "alerts", "installed, but not running — nothing on this Mac will show you a teammate's message", "pantheon service restart notifier (loads it back; something unloaded it — a stop, a crash loop, or a test run on this machine)", "installed, not running");
230
+ }
231
+ else {
232
+ add("pass", "alerts", "installed and running — a desktop notification when a teammate needs you", undefined, "installed and running");
233
+ }
219
234
  }
220
235
  if (!hookStates.length && !hook.installed) {
221
236
  add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor on PATH)", "not installed");
package/dist/machine.js CHANGED
@@ -134,22 +134,29 @@ export function dataSummary(dir) {
134
134
  const spaces = files.split("\n").length;
135
135
  return `${acts} acts across ${spaces} space${spaces > 1 ? "s" : ""}`;
136
136
  }
137
- export function stopProc(kind) {
138
- const p = kind === "relay" ? relayProc(8787) : kind === "worker" ? workerProc() : kind === "menubar" ? menubarProc() : notifierProc();
137
+ const defaultStopIo = () => ({
138
+ proc: (kind) => kind === "relay" ? relayProc(8787) : kind === "worker" ? workerProc() : kind === "menubar" ? menubarProc() : notifierProc(),
139
+ installed: (kind) => existsSync(plistPath(kind)),
140
+ sh,
141
+ });
142
+ export function stopProc(kind, io = defaultStopIo()) {
143
+ const p = io.proc(kind);
139
144
  if (!p.running)
140
145
  return [`${kind}: already stopped`];
141
146
  if (p.managedBy === "launchd") {
147
+ if (!io.installed(kind))
148
+ return [`${kind}: running under launchd, but not installed from this home — left alone`];
142
149
  const plist = plistPath(kind);
143
150
  const uid = process.getuid?.() ?? 0;
144
- if (sh(`launchctl bootout gui/${uid}/dev.pantheon.${kind} 2>&1`) !== null ||
145
- sh(`launchctl unload -w "${plist}" 2>&1`) !== null) {
151
+ if (io.sh(`launchctl bootout gui/${uid}/dev.pantheon.${kind} 2>&1`) !== null ||
152
+ io.sh(`launchctl unload -w "${plist}" 2>&1`) !== null) {
146
153
  return [`${kind}: stopped, and will not restart at login`,
147
154
  ` start it again with: pantheon service install ${kind}`];
148
155
  }
149
156
  return [`${kind}: could not stop the service — try: launchctl bootout gui/${uid}/dev.pantheon.${kind}`];
150
157
  }
151
158
  if (p.pid) {
152
- sh(`kill ${p.pid}`);
159
+ io.sh(`kill ${p.pid}`);
153
160
  return [`${kind}: stopped pid ${p.pid} (it was started by hand, not as a service)`];
154
161
  }
155
162
  return [`${kind}: running but no pid found — nothing stopped`];
package/dist/relay.js CHANGED
@@ -263,6 +263,15 @@ export class Relay {
263
263
  : {}),
264
264
  });
265
265
  }
266
+ if (url.pathname === "/join") {
267
+ const token = (url.searchParams.get("token") ?? "").trim();
268
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(token))
269
+ return this.json(res, 400, { error: "an invite code is required: /join?token=<code>" });
270
+ const team = this.teamOfInvite(token);
271
+ return team
272
+ ? this.json(res, 200, { team })
273
+ : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
274
+ }
266
275
  if (url.pathname === "/act") {
267
276
  const space = this.spaces.get(url.searchParams.get("space") ?? "");
268
277
  const token = url.searchParams.get("token") ?? "";
@@ -713,6 +722,40 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
713
722
  adminCsrf() {
714
723
  return createHash("sha256").update("pantheon-admin-csrf " + (this.adminToken ?? "")).digest("hex").slice(0, 32);
715
724
  }
725
+ teamOfInvite(token) {
726
+ const exact = (al) => { const inv = al.findInvite(token); return !!inv && inv.token === token; };
727
+ for (const sp of this.spaces.values())
728
+ if (exact(sp.allow))
729
+ return sp.name;
730
+ const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
731
+ const live = new Set([...this.spaces.keys()].map(safeOf));
732
+ let files = [];
733
+ try {
734
+ files = readdirSync(this.dataDir);
735
+ }
736
+ catch {
737
+ return null;
738
+ }
739
+ for (const f of files) {
740
+ if (!f.endsWith(".members.json"))
741
+ continue;
742
+ const safe = f.slice(0, -".members.json".length);
743
+ if (live.has(safe))
744
+ continue;
745
+ try {
746
+ if (!exact(new Allowlist(this.dataDir, safe)))
747
+ continue;
748
+ const named = this.sleepingRecords(safe).find((r) => r.space)?.space;
749
+ if (named)
750
+ return named;
751
+ if (safeOf(safe) === safe && !/_/.test(safe))
752
+ return safe;
753
+ return null;
754
+ }
755
+ catch { }
756
+ }
757
+ return null;
758
+ }
716
759
  fleetHealth(now = Date.now()) {
717
760
  const out = [];
718
761
  const seen = new Set();
package/dist/service.js CHANGED
@@ -164,14 +164,22 @@ export function uninstallService(kind) {
164
164
  export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
165
165
  if (!serviceInstalled(kind))
166
166
  return [` ${kind}: not installed here`];
167
+ const uid = process.getuid?.() ?? 501;
167
168
  try {
168
169
  if (platform() === "darwin")
169
- exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 501}/${LABEL(kind)}`]);
170
+ exec("launchctl", ["kickstart", "-k", `gui/${uid}/${LABEL(kind)}`]);
170
171
  else
171
172
  exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
172
173
  return [` ${kind}: restarted on the new code`];
173
174
  }
174
175
  catch (e) {
176
+ if (platform() === "darwin") {
177
+ try {
178
+ exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
179
+ return [` ${kind}: was not loaded in launchd — loaded and started on the new code`];
180
+ }
181
+ catch { }
182
+ }
175
183
  return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
176
184
  }
177
185
  }
package/dist/upgrade.js CHANGED
@@ -16,6 +16,16 @@ function pkgVersion(root) {
16
16
  }
17
17
  }
18
18
  export const PKG_NAME = "@join-pantheon/cli";
19
+ export function rewireVia(root, fallback, run = (file, args) => execFileSync(process.execPath, [file, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120_000 })) {
20
+ try {
21
+ const out = JSON.parse(run(join(root, "dist", "cli.js"), ["rewire", "--json"]));
22
+ if (Array.isArray(out) && out.every((o) => o && typeof o.tool === "string" && typeof o.status === "string")) {
23
+ return out.map((o) => ({ tool: o.tool, status: o.status }));
24
+ }
25
+ }
26
+ catch { }
27
+ return fallback();
28
+ }
19
29
  export function defaultDeps() {
20
30
  const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
21
31
  return {
@@ -29,7 +39,7 @@ export function defaultDeps() {
29
39
  pull: (root) => git(root, "pull", "--ff-only"),
30
40
  build: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
31
41
  version: pkgVersion,
32
- rewire: () => installForUser().map((t) => ({ tool: t.tool, status: t.status })),
42
+ rewire: () => rewireVia(repoRoot(), () => installForUser().map((t) => ({ tool: t.tool, status: t.status }))),
33
43
  running: () => {
34
44
  const up = [];
35
45
  try {
@@ -182,12 +182,18 @@ final class RowView: NSView {
182
182
  private var copied = false
183
183
 
184
184
  private let titleLines: Int
185
+ /// The menu is the board people read every day; 430 wrapped a plain sentence
186
+ /// onto two lines and truncated a decision. 520 carries ~40% more per line.
187
+ static let rowWidth: CGFloat = 520
185
188
  private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
186
189
  private static let lineHeight: CGFloat = 17
187
190
 
188
- /// How many lines this title needs, at most two. A one-line row truncated
191
+ /// How many lines this title needs, at most three. A one-line row truncated
189
192
  /// mid-word ("Sid asked: Sid is asking: is this work in producti…") hid the
190
- /// only part worth reading; two lines carry almost every real title whole.
193
+ /// only part worth reading; two lines carried most titles, and a decision
194
+ /// ("Praneet wants the unattended worker able to do everything overlapping
195
+ /// Pantheo…") still lost its point at the end of the second. Three lines,
196
+ /// taken only when needed, carry every real title whole.
191
197
  static func lines(for text: String, width: CGFloat) -> Int {
192
198
  let para = NSMutableParagraphStyle()
193
199
  para.lineBreakMode = .byWordWrapping
@@ -195,14 +201,14 @@ final class RowView: NSView {
195
201
  with: NSSize(width: width, height: .greatestFiniteMagnitude),
196
202
  options: [.usesLineFragmentOrigin, .usesFontLeading],
197
203
  attributes: [.font: titleFont, .paragraphStyle: para]).height
198
- return max(1, min(2, Int(ceil(h / lineHeight))))
204
+ return max(1, min(3, Int(ceil(h / lineHeight))))
199
205
  }
200
206
 
201
207
  init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
202
208
  self.item = item
203
209
  self.onOpen = onOpen
204
210
  self.onCopy = onCopy
205
- self.titleLines = RowView.lines(for: item.title, width: width - 14 - 48)
211
+ self.titleLines = RowView.lines(for: item.title, width: width - RowView.textInsets)
206
212
  let textHeight = CGFloat(titleLines) * RowView.lineHeight
207
213
  super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (item.subtitle.isEmpty ? 13 : 29)))
208
214
  setAccessibilityRole(.button)
@@ -210,7 +216,9 @@ final class RowView: NSView {
210
216
  }
211
217
  required init?(coder: NSCoder) { return nil }
212
218
 
213
- private var copyRect: NSRect { NSRect(x: bounds.maxX - 42, y: bounds.midY - 12, width: 28, height: 24) }
219
+ /// Left inset plus what the copy control needs on the right: 28 for the control, 8 clear of the text, 8 to the edge.
220
+ static let textInsets: CGFloat = 12 + 44
221
+ private var copyRect: NSRect { NSRect(x: bounds.maxX - 36, y: bounds.midY - 12, width: 28, height: 24) }
214
222
 
215
223
 
216
224
  override func updateTrackingAreas() {
@@ -253,7 +261,7 @@ final class RowView: NSView {
253
261
  para.lineSpacing = 0
254
262
  let flat = NSMutableParagraphStyle()
255
263
  flat.lineBreakMode = .byTruncatingTail
256
- let textWidth = bounds.width - 14 - 48
264
+ let textWidth = bounds.width - RowView.textInsets
257
265
  let hasSub = !item.subtitle.isEmpty
258
266
  let titleHeight = CGFloat(titleLines) * RowView.lineHeight
259
267
  let titleY = bounds.maxY - 7 - titleHeight
@@ -856,7 +864,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
856
864
  for item in items.prefix(6) {
857
865
  let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
858
866
  mi.toolTip = item.detail
859
- mi.view = RowView(item: item, width: 430,
867
+ mi.view = RowView(item: item, width: RowView.rowWidth,
860
868
  onOpen: { [weak self] it in self?.showDetail(it) },
861
869
  onCopy: { [weak self] it in self?.copyPrompt(it) })
862
870
  menu.addItem(mi)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.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": {