@join-pantheon/cli 0.20.0 → 0.21.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 +21 -4
- package/dist/accounts.js +136 -9
- package/dist/agents.js +4 -0
- package/dist/cli.js +155 -18
- package/dist/client.js +23 -8
- package/dist/config.js +14 -0
- package/dist/doctor.js +9 -0
- package/dist/helpers-build.js +23 -0
- package/dist/mcp.js +43 -9
- package/dist/notifier.js +29 -6
- package/dist/notify.js +1 -1
- package/dist/pending.js +3 -1
- package/dist/plans.js +46 -0
- package/dist/profile.js +5 -0
- package/dist/protocol.js +13 -1
- package/dist/quickstart.js +2 -1
- package/dist/relay.js +247 -28
- package/dist/roster-file.js +15 -2
- package/dist/service.js +14 -0
- package/dist/setup-guide.js +268 -0
- package/dist/tray.js +3 -0
- package/dist/untrusted.js +16 -3
- package/dist/upgrade.js +11 -1
- package/dist/worker.js +78 -13
- package/helper/macos/menubar.swift +47 -14
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -67,7 +67,10 @@ every Conductor window, nothing to commit), the wake-up hook so an agent finishe
|
|
|
67
67
|
waiting for, the **worker** so the machine answers the team even while its human is away (on by
|
|
68
68
|
default when run inside a git repo; `--no-worker` opts out — and a peer still needs an explicit
|
|
69
69
|
`--may-wake` grant before they can use it), native decision alerts, and the menu bar. Each command
|
|
70
|
-
ends with a "You're set" summary
|
|
70
|
+
ends with a "You're set" summary, then a short guided setup on a terminal: whether teammates may wake
|
|
71
|
+
your machine while you are away, what a woken run may execute, who you are and which decisions are
|
|
72
|
+
yours alone (so agents stop and ask you), and which alerts you want. Plain questions, mostly options;
|
|
73
|
+
`pantheon setup` re-runs it any time. Restart your agent once and ask it *"check pantheon_status"*.
|
|
71
74
|
|
|
72
75
|
Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
|
|
73
76
|
`pantheon install --user` (from your project's repo).
|
|
@@ -89,6 +92,15 @@ pantheon: that invite was already used by "alex" — ask for a fresh one
|
|
|
89
92
|
|
|
90
93
|
Need another (a third person, or your own second machine)? `pantheon invite`.
|
|
91
94
|
|
|
95
|
+
### Plans on the hosted relay
|
|
96
|
+
|
|
97
|
+
A free account creates **one space** with **two people** in it — you and one invite. Personas,
|
|
98
|
+
messages and history are not limited. Joining spaces other people created is not limited either,
|
|
99
|
+
so a colleague on a bigger plan can always invite you. When you hit a limit the command says so
|
|
100
|
+
and what to do; `pantheon status` shows your plan and how much of it you are using. Need more?
|
|
101
|
+
[joinpantheon.network](https://www.joinpantheon.network). A space you founded and no longer need
|
|
102
|
+
can be dissolved with `pantheon space delete <team> --yes`, which frees the slot.
|
|
103
|
+
|
|
92
104
|
### Reaching each other across cities
|
|
93
105
|
|
|
94
106
|
On the hosted relay there is nothing to work out: every signed-in machine dials
|
|
@@ -119,6 +131,7 @@ agent opened in a folder bound to no team refuses to send rather than guess.
|
|
|
119
131
|
pantheon spaces # your teams, which repo is bound to which, where this folder points
|
|
120
132
|
pantheon use mobile --here # bind this repo to a team (and register the repo with it)
|
|
121
133
|
pantheon repo list # the repos a team works from — a delivery must come from one of them
|
|
134
|
+
pantheon repo add --base main # the branch workers cut from and deliveries are judged against (default: origin's HEAD)
|
|
122
135
|
pantheon leave mobile # drop one team from this machine
|
|
123
136
|
pantheon profile set --space mobile --human-required pricing # authority that applies in one team only
|
|
124
137
|
```
|
|
@@ -486,8 +499,12 @@ Security details and how to report a vulnerability:
|
|
|
486
499
|
## Upgrading
|
|
487
500
|
|
|
488
501
|
```bash
|
|
489
|
-
pantheon upgrade # installs the latest release and
|
|
502
|
+
pantheon upgrade # installs the latest release and finishes the job
|
|
490
503
|
```
|
|
491
504
|
|
|
492
|
-
Every command prints its version and nudges you when a newer release exists.
|
|
493
|
-
|
|
505
|
+
Every command prints its version and nudges you when a newer release exists. `pantheon upgrade`
|
|
506
|
+
installs the new release, re-wires your agents, restarts pantheon's own background services (the
|
|
507
|
+
worker, alerts, menu bar) so they load the new code, and asks any setup question the new version
|
|
508
|
+
has that you have never answered. The one thing it cannot do is restart your agent windows: quit
|
|
509
|
+
and reopen them so they load the new server. A self-hosted relay is shared with your teammates, so
|
|
510
|
+
it prints the restart for that and leaves the timing to you.
|
package/dist/accounts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
3
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { chmodSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
@@ -104,7 +105,29 @@ export class Accounts {
|
|
|
104
105
|
kind TEXT NOT NULL,
|
|
105
106
|
created_at INTEGER NOT NULL
|
|
106
107
|
);
|
|
108
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
109
|
+
key TEXT PRIMARY KEY,
|
|
110
|
+
json TEXT NOT NULL,
|
|
111
|
+
updated_at INTEGER NOT NULL
|
|
112
|
+
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS spaces (
|
|
114
|
+
name TEXT PRIMARY KEY,
|
|
115
|
+
owner_account TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
|
116
|
+
created_at INTEGER NOT NULL,
|
|
117
|
+
deleted_at INTEGER
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_account);
|
|
107
120
|
`);
|
|
121
|
+
const cols = new Set(this.db.prepare("PRAGMA table_info(accounts)").all().map((c) => c.name));
|
|
122
|
+
if (!cols.has("plan"))
|
|
123
|
+
this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
|
|
124
|
+
if (!cols.has("limits_json"))
|
|
125
|
+
this.db.exec("ALTER TABLE accounts ADD COLUMN limits_json TEXT");
|
|
126
|
+
const scols = new Set(this.db.prepare("PRAGMA table_info(sessions)").all().map((c) => c.name));
|
|
127
|
+
if (!scols.has("ended_at"))
|
|
128
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN ended_at INTEGER");
|
|
129
|
+
if (!scols.has("role"))
|
|
130
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN role TEXT");
|
|
108
131
|
}
|
|
109
132
|
close() { this.db.close(); }
|
|
110
133
|
accountByUsername(username) {
|
|
@@ -201,7 +224,7 @@ export class Accounts {
|
|
|
201
224
|
else {
|
|
202
225
|
const id = newId("acct");
|
|
203
226
|
run("INSERT INTO accounts (id, email, username, status, created_at, tos_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, e, v.pending_username, now(), now());
|
|
204
|
-
account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now() };
|
|
227
|
+
account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now(), plan: "free", limits_json: null };
|
|
205
228
|
}
|
|
206
229
|
}
|
|
207
230
|
else {
|
|
@@ -231,18 +254,28 @@ export class Accounts {
|
|
|
231
254
|
this.audit({ actor_account: accountId, event: "device_added", target: d.fingerprint });
|
|
232
255
|
return d;
|
|
233
256
|
}
|
|
234
|
-
openSession(accountId, deviceId, space, ip) {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
257
|
+
openSession(accountId, deviceId, space, ip, role = null) {
|
|
258
|
+
const prior = this.db.prepare("SELECT ip FROM sessions WHERE device_id = ? ORDER BY created_at DESC LIMIT 1").get(deviceId);
|
|
259
|
+
const s = { id: newId("ses"), account_id: accountId, device_id: deviceId, space, ip, created_at: now(), last_seen: now(), revoked_at: null, ended_at: null, role };
|
|
260
|
+
this.db.prepare("INSERT INTO sessions (id, account_id, device_id, space, ip, created_at, last_seen, revoked_at, ended_at, role) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)")
|
|
261
|
+
.run(s.id, s.account_id, s.device_id, s.space, s.ip, s.created_at, s.last_seen, role);
|
|
262
|
+
if (!prior)
|
|
263
|
+
this.audit({ actor_account: accountId, event: "session_created", space: space || null, target: s.id, ip });
|
|
264
|
+
else if (prior.ip !== ip)
|
|
265
|
+
this.audit({ actor_account: accountId, event: "device_new_ip", space: space || null, target: deviceId, detail: `${prior.ip} → ${ip}`, ip });
|
|
239
266
|
return s;
|
|
240
267
|
}
|
|
241
268
|
touchSession(id) {
|
|
242
|
-
this.db.prepare("UPDATE sessions SET last_seen = ? WHERE id = ? AND revoked_at IS NULL").run(now(), id);
|
|
269
|
+
this.db.prepare("UPDATE sessions SET last_seen = ? WHERE id = ? AND revoked_at IS NULL AND ended_at IS NULL").run(now(), id);
|
|
270
|
+
}
|
|
271
|
+
endAllOpenSessions() {
|
|
272
|
+
return Number(this.db.prepare("UPDATE sessions SET ended_at = ?, last_seen = last_seen WHERE ended_at IS NULL").run(now()).changes);
|
|
273
|
+
}
|
|
274
|
+
endSession(id) {
|
|
275
|
+
this.db.prepare("UPDATE sessions SET ended_at = ?, last_seen = ? WHERE id = ? AND ended_at IS NULL").run(now(), now(), id);
|
|
243
276
|
}
|
|
244
277
|
sessionsFor(accountId) {
|
|
245
|
-
return this.db.prepare("SELECT * FROM sessions WHERE account_id = ? ORDER BY created_at DESC").all(accountId);
|
|
278
|
+
return this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND ended_at IS NULL ORDER BY created_at DESC").all(accountId);
|
|
246
279
|
}
|
|
247
280
|
devicesFor(accountId) {
|
|
248
281
|
return this.db.prepare("SELECT * FROM devices WHERE account_id = ? ORDER BY created_at ASC").all(accountId);
|
|
@@ -340,12 +373,105 @@ export class Accounts {
|
|
|
340
373
|
recentVerifications(limit = 50) {
|
|
341
374
|
return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
342
375
|
}
|
|
376
|
+
plans() {
|
|
377
|
+
const row = this.db.prepare("SELECT json FROM settings WHERE key = 'plans'").get();
|
|
378
|
+
if (row) {
|
|
379
|
+
try {
|
|
380
|
+
const p = parsePlans(JSON.parse(row.json));
|
|
381
|
+
if (p)
|
|
382
|
+
return p;
|
|
383
|
+
}
|
|
384
|
+
catch { }
|
|
385
|
+
}
|
|
386
|
+
if (process.env.PANTHEON_PLANS_JSON) {
|
|
387
|
+
try {
|
|
388
|
+
const p = parsePlans(JSON.parse(process.env.PANTHEON_PLANS_JSON));
|
|
389
|
+
if (p)
|
|
390
|
+
return p;
|
|
391
|
+
}
|
|
392
|
+
catch { }
|
|
393
|
+
}
|
|
394
|
+
return structuredClone(DEFAULT_PLANS);
|
|
395
|
+
}
|
|
396
|
+
setPlans(plans) {
|
|
397
|
+
if (!parsePlans(plans))
|
|
398
|
+
throw new Error("malformed plan table");
|
|
399
|
+
this.db.prepare("INSERT INTO settings (key, json, updated_at) VALUES ('plans', ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at")
|
|
400
|
+
.run(JSON.stringify(plans), now());
|
|
401
|
+
}
|
|
402
|
+
setPlan(accountId, plan) {
|
|
403
|
+
if (!this.plans()[plan])
|
|
404
|
+
return false;
|
|
405
|
+
return this.db.prepare("UPDATE accounts SET plan = ? WHERE id = ?").run(plan, accountId).changes > 0;
|
|
406
|
+
}
|
|
407
|
+
setLimits(accountId, json) {
|
|
408
|
+
if (json !== null) {
|
|
409
|
+
let ok = false;
|
|
410
|
+
try {
|
|
411
|
+
ok = !!parseLimits(JSON.parse(json));
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
ok = false;
|
|
415
|
+
}
|
|
416
|
+
if (!ok)
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
return this.db.prepare("UPDATE accounts SET limits_json = ? WHERE id = ?").run(json, accountId).changes > 0;
|
|
420
|
+
}
|
|
421
|
+
limitsFor(acct) { return planLimits(this.plans(), acct.plan, acct.limits_json); }
|
|
422
|
+
planCounts() {
|
|
423
|
+
const out = {};
|
|
424
|
+
for (const r of this.db.prepare("SELECT plan, COUNT(*) c FROM accounts GROUP BY plan").all())
|
|
425
|
+
out[r.plan] = r.c;
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
planLimitHits() {
|
|
429
|
+
const m = new Map();
|
|
430
|
+
for (const r of this.db.prepare("SELECT actor_account a, COUNT(*) c FROM audit WHERE event = 'plan_limit' AND actor_account IS NOT NULL GROUP BY actor_account").all())
|
|
431
|
+
m.set(r.a, r.c);
|
|
432
|
+
return m;
|
|
433
|
+
}
|
|
434
|
+
recordSpace(name, ownerAccount) {
|
|
435
|
+
this.db.prepare("INSERT INTO spaces (name, owner_account, created_at, deleted_at) VALUES (?, ?, ?, NULL) " +
|
|
436
|
+
"ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
|
|
437
|
+
}
|
|
438
|
+
markSpaceDeleted(name) {
|
|
439
|
+
this.db.prepare("UPDATE spaces SET deleted_at = ? WHERE name = ? AND deleted_at IS NULL").run(now(), name);
|
|
440
|
+
}
|
|
441
|
+
spaceOwner(name) {
|
|
442
|
+
const r = this.db.prepare("SELECT owner_account FROM spaces WHERE name = ? AND deleted_at IS NULL").get(name);
|
|
443
|
+
return r?.owner_account ? this.accountById(r.owner_account) : null;
|
|
444
|
+
}
|
|
445
|
+
spacesOwned(accountId) {
|
|
446
|
+
return this.db.prepare("SELECT name FROM spaces WHERE owner_account = ? AND deleted_at IS NULL ORDER BY created_at").all(accountId).map((r) => r.name);
|
|
447
|
+
}
|
|
448
|
+
spaceOwners() {
|
|
449
|
+
const m = new Map();
|
|
450
|
+
for (const r of this.db.prepare("SELECT name, owner_account FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL").all())
|
|
451
|
+
m.set(r.name, r.owner_account);
|
|
452
|
+
return m;
|
|
453
|
+
}
|
|
454
|
+
ownedCounts() {
|
|
455
|
+
const m = new Map();
|
|
456
|
+
for (const r of this.db.prepare("SELECT owner_account a, COUNT(*) c FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL GROUP BY owner_account").all())
|
|
457
|
+
m.set(r.a, r.c);
|
|
458
|
+
return m;
|
|
459
|
+
}
|
|
460
|
+
hasSpaceRecord(name) {
|
|
461
|
+
return !!this.db.prepare("SELECT 1 FROM spaces WHERE name = ?").get(name);
|
|
462
|
+
}
|
|
343
463
|
allAccounts() {
|
|
344
464
|
return this.db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all();
|
|
345
465
|
}
|
|
346
466
|
allDevices() {
|
|
347
467
|
return this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
|
|
348
468
|
}
|
|
469
|
+
liveSessions(limit = 500) {
|
|
470
|
+
return this.db.prepare("SELECT * FROM sessions WHERE ended_at IS NULL AND revoked_at IS NULL ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
471
|
+
}
|
|
472
|
+
endedSessionsSince(sinceMs) {
|
|
473
|
+
return this.db.prepare("SELECT COUNT(*) c FROM sessions WHERE ended_at IS NOT NULL AND ended_at >= ?").get(sinceMs).c;
|
|
474
|
+
}
|
|
349
475
|
allSessions(limit = 500) {
|
|
350
476
|
return this.db.prepare("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
351
477
|
}
|
|
@@ -355,7 +481,7 @@ export class Accounts {
|
|
|
355
481
|
accounts: n("SELECT COUNT(*) c FROM accounts"),
|
|
356
482
|
devices: n("SELECT COUNT(*) c FROM devices"),
|
|
357
483
|
sessions: n("SELECT COUNT(*) c FROM sessions"),
|
|
358
|
-
live_sessions: n("SELECT COUNT(*) c FROM sessions WHERE revoked_at IS NULL"),
|
|
484
|
+
live_sessions: n("SELECT COUNT(*) c FROM sessions WHERE revoked_at IS NULL AND ended_at IS NULL"),
|
|
359
485
|
audit: n("SELECT COUNT(*) c FROM audit"),
|
|
360
486
|
};
|
|
361
487
|
}
|
|
@@ -363,6 +489,7 @@ export class Accounts {
|
|
|
363
489
|
const sessionDays = opts.sessionDays ?? 30;
|
|
364
490
|
const auditDays = opts.auditDays ?? 365;
|
|
365
491
|
const sCut = now() - sessionDays * 86_400_000;
|
|
492
|
+
this.db.prepare("DELETE FROM sessions WHERE ended_at IS NOT NULL AND ended_at < ?").run(now() - 7 * 86_400_000);
|
|
366
493
|
const s = this.db.prepare("DELETE FROM sessions WHERE last_seen < ?").run(sCut);
|
|
367
494
|
let a = { changes: 0 };
|
|
368
495
|
if (auditDays > 0)
|
package/dist/agents.js
CHANGED
|
@@ -93,6 +93,8 @@ export function headlessSpawn(opts) {
|
|
|
93
93
|
PANTHEON_AGENT: opts.backend.id,
|
|
94
94
|
PANTHEON_ROLE: "worker",
|
|
95
95
|
PANTHEON_BRANCH: opts.branch,
|
|
96
|
+
...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
|
|
97
|
+
...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
|
|
96
98
|
...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
|
|
97
99
|
...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
|
|
98
100
|
};
|
|
@@ -101,6 +103,8 @@ export function headlessSpawn(opts) {
|
|
|
101
103
|
PANTHEON_AGENT: opts.backend.id,
|
|
102
104
|
PANTHEON_ROLE: "worker",
|
|
103
105
|
PANTHEON_BRANCH: opts.branch,
|
|
106
|
+
...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
|
|
107
|
+
...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
|
|
104
108
|
...(home ? { PANTHEON_HOME: home } : {}),
|
|
105
109
|
};
|
|
106
110
|
if (opts.backend.id === "claude-code") {
|
package/dist/cli.js
CHANGED
|
@@ -7,16 +7,19 @@ process.emitWarning = ((warning, ...rest) => {
|
|
|
7
7
|
return _emitWarning(warning, ...rest);
|
|
8
8
|
});
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
10
11
|
import { dirname, join, resolve } from "node:path";
|
|
11
12
|
import { PantheonClient } from "./client.js";
|
|
12
|
-
import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY } from "./config.js";
|
|
13
|
+
import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
|
|
14
|
+
import { fmtLimit } from "./plans.js";
|
|
13
15
|
import { validEmail, validUsername } from "./accounts.js";
|
|
14
16
|
import { install, launcher } from "./install.js";
|
|
15
17
|
import { Relay } from "./relay.js";
|
|
16
18
|
import { runMcp } from "./mcp.js";
|
|
17
19
|
import { Worker } from "./worker.js";
|
|
18
20
|
import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
|
|
19
|
-
import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile } from "./profile.js";
|
|
21
|
+
import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
|
|
22
|
+
import { runGuide, terminalIO } from "./setup-guide.js";
|
|
20
23
|
import { doctor, render } from "./doctor.js";
|
|
21
24
|
import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
|
|
22
25
|
import { assess } from "./untrusted.js";
|
|
@@ -266,6 +269,66 @@ function unattendedProject() {
|
|
|
266
269
|
return { project: resolve(flag("project", ".")), how: "explicit" };
|
|
267
270
|
return { project: resolve("."), how: "default" };
|
|
268
271
|
}
|
|
272
|
+
async function guidedSetup(cfg, project, opts = {}) {
|
|
273
|
+
if (argv.includes("--no-guide"))
|
|
274
|
+
return;
|
|
275
|
+
const interactive = !!process.stdin.isTTY && !argv.includes("--yes");
|
|
276
|
+
if (!interactive && !opts.force)
|
|
277
|
+
return;
|
|
278
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : null;
|
|
279
|
+
const io = rl ? terminalIO(rl, (l) => console.error(l)) : null;
|
|
280
|
+
console.error(`\n${B("A few choices, then you're done.")}${io ? "" : D(" (no terminal — taking the defaults)")}`);
|
|
281
|
+
try {
|
|
282
|
+
const r = await runGuide(io, { cfg, project }, {
|
|
283
|
+
publish: async (p) => {
|
|
284
|
+
const c = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
285
|
+
try {
|
|
286
|
+
await c.connect();
|
|
287
|
+
await c.publishProfile(organizationProfile(p, cfg.spaceId, cfg.space));
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
c.close();
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
...(project ? {
|
|
294
|
+
setRepoBase: async (base) => {
|
|
295
|
+
const info = repoInfo(project);
|
|
296
|
+
if (!info)
|
|
297
|
+
throw new Error("not inside a git repo");
|
|
298
|
+
const { repo: _r, repoOrigin: _o, ...plain } = cfg;
|
|
299
|
+
const c = new PantheonClient(plain, undefined, { role: "cli" });
|
|
300
|
+
try {
|
|
301
|
+
await c.connect();
|
|
302
|
+
await c.bindRepo(info.fingerprint, info.origin, base);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
c.close();
|
|
306
|
+
}
|
|
307
|
+
if (cfg.spaceId)
|
|
308
|
+
bindRepo(info, cfg.spaceId);
|
|
309
|
+
},
|
|
310
|
+
} : {}),
|
|
311
|
+
});
|
|
312
|
+
console.error("");
|
|
313
|
+
for (const l of r.applied)
|
|
314
|
+
console.error(` ${C("✓")} ${l}`);
|
|
315
|
+
for (const w of r.warnings)
|
|
316
|
+
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
317
|
+
console.error(D(`\n Change any of this later: pantheon setup`));
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
rl?.close();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function workerMidRun() {
|
|
324
|
+
try {
|
|
325
|
+
const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
|
|
326
|
+
return !!s.at && Date.now() - s.at < 10 * 60_000 && /run/i.test(String(s.state ?? ""));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
269
332
|
function printReady(tools) {
|
|
270
333
|
const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
|
|
271
334
|
console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
|
|
@@ -317,7 +380,9 @@ ${B("several teams on one machine")}
|
|
|
317
380
|
pantheon spaces your teams, which repo is bound to which, and where this folder points
|
|
318
381
|
pantheon use <team> --here bind this repo to a team (agents opened here speak in it)
|
|
319
382
|
pantheon repo add | list the repos a team works from; a delivery must come from one of them
|
|
383
|
+
(repo add --base <branch>: the branch workers cut from, else origin's default)
|
|
320
384
|
pantheon leave <team> drop one team from this machine (its token and repo bindings)
|
|
385
|
+
pantheon space delete <team> --yes dissolve a team you founded: disconnects everyone, frees a plan slot
|
|
321
386
|
pantheon profile set --space <team> … authority that applies in one team only (e.g. --human-required pricing)
|
|
322
387
|
|
|
323
388
|
${B("day to day")}
|
|
@@ -350,6 +415,8 @@ ${B("control")}
|
|
|
350
415
|
pantheon leave remove what pantheon put on this machine
|
|
351
416
|
pantheon panic cut off all unattended work on this machine, now
|
|
352
417
|
pantheon doctor check this machine end to end
|
|
418
|
+
pantheon setup the guided choices: who may wake this machine, what a run may execute,
|
|
419
|
+
what you personally decide, which alerts you want — re-run any time
|
|
353
420
|
pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
|
|
354
421
|
|
|
355
422
|
${D("more: pantheon help --all")}
|
|
@@ -357,7 +424,7 @@ ${D("more: pantheon help --all")}
|
|
|
357
424
|
const HELP_ALL = HELP + `
|
|
358
425
|
${B("less common")}
|
|
359
426
|
pantheon relay run only the relay, in this terminal
|
|
360
|
-
pantheon service install [relay|worker|notifier|menubar] | uninstall
|
|
427
|
+
pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
|
|
361
428
|
run a long-lived component in the background, from login
|
|
362
429
|
pantheon members the full list, with key fingerprints
|
|
363
430
|
pantheon whoami your name and fingerprint
|
|
@@ -428,6 +495,8 @@ async function main() {
|
|
|
428
495
|
console.error(D(` ${r.reach.hint}`));
|
|
429
496
|
}
|
|
430
497
|
printReady(r.tools);
|
|
498
|
+
if (r.claimed)
|
|
499
|
+
await guidedSetup(load() ?? r.cfg, repoInfo(process.cwd())?.root ?? null);
|
|
431
500
|
if (r.relay) {
|
|
432
501
|
console.error(D(`\nKeeping the relay running here. Ctrl-C stops it (your teammate goes offline too).`));
|
|
433
502
|
await new Promise(() => { });
|
|
@@ -440,7 +509,8 @@ async function main() {
|
|
|
440
509
|
await client.connect().catch((e) => die(client.pending ?? e.message));
|
|
441
510
|
await new Promise((r) => setTimeout(r, 250));
|
|
442
511
|
const ttl = Number(flag("ttl", "1440"));
|
|
443
|
-
const
|
|
512
|
+
const mayWake = argv.includes("--may-wake") ? true : argv.includes("--no-wake") ? false : wakeDefault(loadProfile(cfg));
|
|
513
|
+
const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
|
|
444
514
|
console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
|
|
445
515
|
console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
|
|
446
516
|
client.close();
|
|
@@ -558,6 +628,11 @@ async function main() {
|
|
|
558
628
|
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
559
629
|
printGranted(r.granted);
|
|
560
630
|
printReady(r.tools);
|
|
631
|
+
{
|
|
632
|
+
const joined = load();
|
|
633
|
+
if (joined)
|
|
634
|
+
await guidedSetup(joined, repoInfo(process.cwd())?.root ?? null);
|
|
635
|
+
}
|
|
561
636
|
console.error(`\n${D('Then ask your agent: "check pantheon_status and say hello to the team".')}`);
|
|
562
637
|
return;
|
|
563
638
|
}
|
|
@@ -607,15 +682,48 @@ async function main() {
|
|
|
607
682
|
return;
|
|
608
683
|
}
|
|
609
684
|
console.error(`\n${B(`upgraded ${r.from} → ${r.to}`)}`);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
685
|
+
const { restartService, serviceInstalled } = await import("./service.js");
|
|
686
|
+
console.error("");
|
|
687
|
+
if (process.platform === "darwin") {
|
|
688
|
+
const { trayStale, ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
|
|
689
|
+
const { notifierStale, ensureNativeNotifier } = await import("./notifier.js");
|
|
690
|
+
for (const [stale, build, what] of [[trayStale, () => { ensureTrayBinary(); writeTrayCommands(); }, "menu bar"], [notifierStale, ensureNativeNotifier, "alerts"]]) {
|
|
691
|
+
if (!stale())
|
|
692
|
+
continue;
|
|
693
|
+
try {
|
|
694
|
+
build();
|
|
695
|
+
console.error(` ${what}: rebuilt from this release's source`);
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
console.error(` ${what}: could not rebuild (${e.message.split("\n")[0]})`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
for (const kind of ["worker", "notifier", "menubar"]) {
|
|
703
|
+
if (!serviceInstalled(kind))
|
|
704
|
+
continue;
|
|
705
|
+
if (kind === "worker" && workerMidRun()) {
|
|
706
|
+
console.error(` ${D("worker: a run is in progress — left alone. When it is done:")} pantheon service restart worker`);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
for (const l of restartService(kind))
|
|
710
|
+
console.error(l);
|
|
711
|
+
}
|
|
712
|
+
if (r.restart.includes("relay"))
|
|
713
|
+
console.error(` relay: ${D("shared with your teammates, so it is yours to time:")} launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay`);
|
|
714
|
+
const cfgNow = load();
|
|
715
|
+
if (cfgNow) {
|
|
716
|
+
if (loadProfile(cfgNow).agent.wake_default === undefined)
|
|
717
|
+
await guidedSetup(cfgNow, repoInfo(process.cwd())?.root ?? null);
|
|
616
718
|
else
|
|
617
|
-
console.error(
|
|
719
|
+
console.error(D(`\n Your setup answers still apply — pantheon setup to change them.`));
|
|
618
720
|
}
|
|
721
|
+
console.error(`\n${B("One thing only you can do:")} quit and reopen your agent windows so they load the new pantheon.`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
case "setup": {
|
|
725
|
+
const cfg = need();
|
|
726
|
+
await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true });
|
|
619
727
|
return;
|
|
620
728
|
}
|
|
621
729
|
case "version": {
|
|
@@ -636,6 +744,8 @@ async function main() {
|
|
|
636
744
|
await new Promise((r) => setTimeout(r, 350));
|
|
637
745
|
const peers = client.roster.filter((m) => m.id !== cfg.member);
|
|
638
746
|
console.log(`${B(cfg.space)} you are ${C(cfg.member)} ${D(cfg.relay)} ${D(`${client.head} acts`)}\n`);
|
|
747
|
+
if (client.plan)
|
|
748
|
+
console.log(D(` ${client.plan.name} plan · ${client.plan.usage.spaces} of ${fmtLimit(client.plan.limits.spaces)} spaces created · ${client.plan.usage.members} of ${fmtLimit(client.plan.limits.members)} people here${client.plan.limits.wake ? "" : " · no unattended workers"}`) + "\n");
|
|
639
749
|
console.log(B("members"));
|
|
640
750
|
if (!peers.length)
|
|
641
751
|
console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
|
|
@@ -944,7 +1054,7 @@ async function main() {
|
|
|
944
1054
|
}
|
|
945
1055
|
case "hook": {
|
|
946
1056
|
const sub = argv[1] ?? "status";
|
|
947
|
-
const hookCfg =
|
|
1057
|
+
const hookCfg = resolveForHook();
|
|
948
1058
|
const reportIdle = async (session, seen) => {
|
|
949
1059
|
if (!hookCfg)
|
|
950
1060
|
return true;
|
|
@@ -1047,7 +1157,7 @@ async function main() {
|
|
|
1047
1157
|
const { pending: pendingFn } = await import("./pending.js");
|
|
1048
1158
|
const { load: loadCfg } = await import("./config.js");
|
|
1049
1159
|
const d = await decideStop(JSON.stringify({ session_id: session }), {
|
|
1050
|
-
pending: async (s) => pendingFn({ session: s }),
|
|
1160
|
+
pending: async (s) => (hookCfg ? pendingFn({ session: s, cfg: hookCfg }) : Promise.resolve(null)),
|
|
1051
1161
|
...(loadCfg()?.member ? { me: loadCfg().member } : {}),
|
|
1052
1162
|
});
|
|
1053
1163
|
if (d.exit === 2 && d.message) {
|
|
@@ -1080,7 +1190,7 @@ async function main() {
|
|
|
1080
1190
|
const { pending: pendingFn } = await import("./pending.js");
|
|
1081
1191
|
const { load: loadCfg } = await import("./config.js");
|
|
1082
1192
|
const d = await decideStop(raw, {
|
|
1083
|
-
pending: async (session) => pendingFn({ session }),
|
|
1193
|
+
pending: async (session) => (hookCfg ? pendingFn({ session, cfg: hookCfg }) : Promise.resolve(null)),
|
|
1084
1194
|
...(loadCfg()?.member ? { me: loadCfg().member } : {}),
|
|
1085
1195
|
});
|
|
1086
1196
|
if (d.exit === 2 && d.message) {
|
|
@@ -1118,7 +1228,9 @@ async function main() {
|
|
|
1118
1228
|
let surfaced;
|
|
1119
1229
|
const d = await decideStop(raw, {
|
|
1120
1230
|
pending: async (session) => {
|
|
1121
|
-
|
|
1231
|
+
if (!hookCfg)
|
|
1232
|
+
return null;
|
|
1233
|
+
const r = await pendingFn({ session, cfg: hookCfg });
|
|
1122
1234
|
if (r)
|
|
1123
1235
|
surfaced = r.seq;
|
|
1124
1236
|
return r;
|
|
@@ -1188,6 +1300,24 @@ async function main() {
|
|
|
1188
1300
|
}
|
|
1189
1301
|
return;
|
|
1190
1302
|
}
|
|
1303
|
+
case "space": {
|
|
1304
|
+
const sub = argv[1];
|
|
1305
|
+
const name = argv[2];
|
|
1306
|
+
if (sub !== "delete" || !name || name.startsWith("--"))
|
|
1307
|
+
die("usage: pantheon space delete <team> --yes");
|
|
1308
|
+
const all = loadAll() ?? die("not configured");
|
|
1309
|
+
const m = findMembership(all, name) ?? die(`you are not in a team called "${name}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
|
|
1310
|
+
if (!argv.includes("--yes"))
|
|
1311
|
+
die(`this deletes "${m.name}" for everyone in it — its history, goals and invites — and cannot be undone.\n If you mean it: pantheon space delete ${m.name} --yes`);
|
|
1312
|
+
const client = new PantheonClient(configFor(all, m, null), undefined, { role: "cli" });
|
|
1313
|
+
await client.connect().catch((e) => die(client.pending ?? e.message));
|
|
1314
|
+
await client.deleteSpace(m.name).catch((e) => { client.close(); die(e.message); });
|
|
1315
|
+
client.close();
|
|
1316
|
+
const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).length;
|
|
1317
|
+
removeMembership(m.id);
|
|
1318
|
+
console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} repo binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1191
1321
|
case "leave": {
|
|
1192
1322
|
if (argv[1] && !argv[1].startsWith("--")) {
|
|
1193
1323
|
const all = loadAll() ?? die("not configured");
|
|
@@ -2037,6 +2167,12 @@ async function main() {
|
|
|
2037
2167
|
console.error(l);
|
|
2038
2168
|
return;
|
|
2039
2169
|
}
|
|
2170
|
+
if (sub === "restart") {
|
|
2171
|
+
const { restartService } = await import("./service.js");
|
|
2172
|
+
for (const l of restartService(kind))
|
|
2173
|
+
console.error(l);
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2040
2176
|
if (kind === "relay") {
|
|
2041
2177
|
const data = resolveDataDir(flag("data")).dir;
|
|
2042
2178
|
const port = flag("port", "8787");
|
|
@@ -2159,16 +2295,17 @@ async function main() {
|
|
|
2159
2295
|
await client.connect().catch((e) => die(client.pending ?? e.message));
|
|
2160
2296
|
if (sub === "add") {
|
|
2161
2297
|
const r = repoInfo(flag("path") ?? process.cwd()) ?? die("not inside a git repo");
|
|
2162
|
-
|
|
2298
|
+
const base = flag("base") ?? null;
|
|
2299
|
+
await client.bindRepo(r.fingerprint, r.origin, base).catch((e) => die(e.message));
|
|
2163
2300
|
if (cfg.spaceId)
|
|
2164
2301
|
bindRepo(r, cfg.spaceId);
|
|
2165
|
-
console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
|
|
2302
|
+
console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${base ? ` — workers cut from ${C(base)}` : ""}${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
|
|
2166
2303
|
}
|
|
2167
2304
|
else {
|
|
2168
2305
|
await new Promise((res) => setTimeout(res, 150));
|
|
2169
2306
|
console.error(`${B(`repos "${cfg.space}" works from`)} ${D(`(${client.repos.length})`)}`);
|
|
2170
2307
|
for (const r of client.repos)
|
|
2171
|
-
console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}`);
|
|
2308
|
+
console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}${r.base ? ` ${D("base")} ${C(r.base)}` : ""}`);
|
|
2172
2309
|
if (!client.repos.length)
|
|
2173
2310
|
console.error(D(" none yet — the first member to connect from a checkout registers it"));
|
|
2174
2311
|
}
|
package/dist/client.js
CHANGED
|
@@ -38,6 +38,7 @@ export class PantheonClient {
|
|
|
38
38
|
enrolledToken = null;
|
|
39
39
|
spaceId = null;
|
|
40
40
|
repos = [];
|
|
41
|
+
plan = null;
|
|
41
42
|
enroll = null;
|
|
42
43
|
inviteUrl = null;
|
|
43
44
|
constructor(cfg, onEvent, opts) {
|
|
@@ -157,6 +158,7 @@ export class PantheonClient {
|
|
|
157
158
|
this.cfg.spaceId = msg.space_id;
|
|
158
159
|
}
|
|
159
160
|
this.repos = msg.repos ?? [];
|
|
161
|
+
this.plan = msg.plan ?? null;
|
|
160
162
|
this.adoptKnownRepo();
|
|
161
163
|
this.backoff = 500;
|
|
162
164
|
this.roster = msg.roster;
|
|
@@ -375,29 +377,42 @@ export class PantheonClient {
|
|
|
375
377
|
if (c.cwdRepoOrigin)
|
|
376
378
|
c.repoOrigin = c.cwdRepoOrigin;
|
|
377
379
|
}
|
|
378
|
-
|
|
380
|
+
reportWorkerState(state, detail, until) {
|
|
381
|
+
try {
|
|
382
|
+
this.ws?.send(JSON.stringify({ t: "worker_state", state, ...(detail ? { detail: detail.slice(0, 200) } : {}), ...(until ? { until } : {}) }));
|
|
383
|
+
}
|
|
384
|
+
catch { }
|
|
385
|
+
}
|
|
386
|
+
deleteSpace(confirm) {
|
|
387
|
+
return this.rpc({ t: "delete_space", confirm });
|
|
388
|
+
}
|
|
389
|
+
bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null, base = null) {
|
|
379
390
|
if (!fingerprint)
|
|
380
391
|
return Promise.reject(new Error("not inside a git repo — run this from the team's checkout"));
|
|
381
|
-
return this.rpc({ t: "bind_repo", repo: fingerprint, origin });
|
|
392
|
+
return this.rpc({ t: "bind_repo", repo: fingerprint, origin, ...(base ? { base } : {}) });
|
|
382
393
|
}
|
|
383
394
|
makeInvite(mayWake = true, ttlMinutes = 60) {
|
|
384
395
|
return new Promise((res, rej) => {
|
|
385
|
-
const
|
|
396
|
+
const cid = `c${++this.cid}`;
|
|
397
|
+
const finish = () => { clearInterval(poll); clearTimeout(t); this.acks.delete(cid); };
|
|
398
|
+
const t = setTimeout(() => { finish(); rej(new Error("relay did not return an invite in 10s")); }, 10_000);
|
|
386
399
|
const poll = setInterval(() => {
|
|
387
400
|
if (this.inviteUrl) {
|
|
388
|
-
|
|
389
|
-
clearTimeout(t);
|
|
401
|
+
finish();
|
|
390
402
|
const v = this.inviteUrl;
|
|
391
403
|
this.inviteUrl = null;
|
|
392
404
|
res(v);
|
|
393
405
|
}
|
|
394
406
|
}, 40);
|
|
407
|
+
this.acks.set(cid, (v) => { if ("error" in v) {
|
|
408
|
+
finish();
|
|
409
|
+
rej(new Error(v.error));
|
|
410
|
+
} });
|
|
395
411
|
try {
|
|
396
|
-
this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes }));
|
|
412
|
+
this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes, cid }));
|
|
397
413
|
}
|
|
398
414
|
catch (e) {
|
|
399
|
-
|
|
400
|
-
clearTimeout(t);
|
|
415
|
+
finish();
|
|
401
416
|
rej(e);
|
|
402
417
|
}
|
|
403
418
|
});
|