@join-pantheon/cli 0.21.0 → 0.21.2
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 +8 -4
- package/dist/accounts.js +30 -8
- package/dist/cli.js +88 -13
- package/dist/helpers-build.js +23 -0
- package/dist/notifier.js +3 -0
- package/dist/relay.js +35 -9
- package/dist/service.js +14 -0
- package/dist/setup-guide.js +114 -2
- package/dist/tray.js +3 -0
- package/dist/upgrade.js +11 -1
- package/helper/macos/menubar.swift +47 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ default when run inside a git repo; `--no-worker` opts out — and a peer still
|
|
|
70
70
|
ends with a "You're set" summary, then a short guided setup on a terminal: whether teammates may wake
|
|
71
71
|
your machine while you are away, what a woken run may execute, who you are and which decisions are
|
|
72
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"*.
|
|
73
|
+
`pantheon setup` re-runs it any time — and an agent setting you up can run `pantheon setup --questions`, ask you in chat, and pass your answers back as flags, so nobody has to open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
|
|
74
74
|
|
|
75
75
|
Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
|
|
76
76
|
`pantheon install --user` (from your project's repo).
|
|
@@ -499,8 +499,12 @@ Security details and how to report a vulnerability:
|
|
|
499
499
|
## Upgrading
|
|
500
500
|
|
|
501
501
|
```bash
|
|
502
|
-
pantheon upgrade # installs the latest release and
|
|
502
|
+
pantheon upgrade # installs the latest release and finishes the job
|
|
503
503
|
```
|
|
504
504
|
|
|
505
|
-
Every command prints its version and nudges you when a newer release exists.
|
|
506
|
-
|
|
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
|
@@ -123,6 +123,11 @@ export class Accounts {
|
|
|
123
123
|
this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
|
|
124
124
|
if (!cols.has("limits_json"))
|
|
125
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");
|
|
126
131
|
}
|
|
127
132
|
close() { this.db.close(); }
|
|
128
133
|
accountByUsername(username) {
|
|
@@ -249,18 +254,28 @@ export class Accounts {
|
|
|
249
254
|
this.audit({ actor_account: accountId, event: "device_added", target: d.fingerprint });
|
|
250
255
|
return d;
|
|
251
256
|
}
|
|
252
|
-
openSession(accountId, deviceId, space, ip) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 });
|
|
257
266
|
return s;
|
|
258
267
|
}
|
|
259
268
|
touchSession(id) {
|
|
260
|
-
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);
|
|
261
276
|
}
|
|
262
277
|
sessionsFor(accountId) {
|
|
263
|
-
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);
|
|
264
279
|
}
|
|
265
280
|
devicesFor(accountId) {
|
|
266
281
|
return this.db.prepare("SELECT * FROM devices WHERE account_id = ? ORDER BY created_at ASC").all(accountId);
|
|
@@ -451,6 +466,12 @@ export class Accounts {
|
|
|
451
466
|
allDevices() {
|
|
452
467
|
return this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
|
|
453
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
|
+
}
|
|
454
475
|
allSessions(limit = 500) {
|
|
455
476
|
return this.db.prepare("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
456
477
|
}
|
|
@@ -460,7 +481,7 @@ export class Accounts {
|
|
|
460
481
|
accounts: n("SELECT COUNT(*) c FROM accounts"),
|
|
461
482
|
devices: n("SELECT COUNT(*) c FROM devices"),
|
|
462
483
|
sessions: n("SELECT COUNT(*) c FROM sessions"),
|
|
463
|
-
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"),
|
|
464
485
|
audit: n("SELECT COUNT(*) c FROM audit"),
|
|
465
486
|
};
|
|
466
487
|
}
|
|
@@ -468,6 +489,7 @@ export class Accounts {
|
|
|
468
489
|
const sessionDays = opts.sessionDays ?? 30;
|
|
469
490
|
const auditDays = opts.auditDays ?? 365;
|
|
470
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);
|
|
471
493
|
const s = this.db.prepare("DELETE FROM sessions WHERE last_seen < ?").run(sCut);
|
|
472
494
|
let a = { changes: 0 };
|
|
473
495
|
if (auditDays > 0)
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ 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
13
|
import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
|
|
@@ -18,7 +19,7 @@ import { runMcp } from "./mcp.js";
|
|
|
18
19
|
import { Worker } from "./worker.js";
|
|
19
20
|
import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
|
|
20
21
|
import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
|
|
21
|
-
import { runGuide, terminalIO } from "./setup-guide.js";
|
|
22
|
+
import { runGuide, terminalIO, parseSetupFlags, questionnaire } from "./setup-guide.js";
|
|
22
23
|
import { doctor, render } from "./doctor.js";
|
|
23
24
|
import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
|
|
24
25
|
import { assess } from "./untrusted.js";
|
|
@@ -271,12 +272,13 @@ function unattendedProject() {
|
|
|
271
272
|
async function guidedSetup(cfg, project, opts = {}) {
|
|
272
273
|
if (argv.includes("--no-guide"))
|
|
273
274
|
return;
|
|
274
|
-
const
|
|
275
|
+
const preset = opts.preset ?? {};
|
|
276
|
+
const interactive = !!process.stdin.isTTY && !argv.includes("--yes") && !Object.keys(preset).length;
|
|
275
277
|
if (!interactive && !opts.force)
|
|
276
278
|
return;
|
|
277
279
|
const rl = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : null;
|
|
278
280
|
const io = rl ? terminalIO(rl, (l) => console.error(l)) : null;
|
|
279
|
-
console.error(`\n${B("A few choices, then you're done.")}${io ? "" : D(" (no terminal — taking the defaults)")}`);
|
|
281
|
+
console.error(`\n${B("A few choices, then you're done.")}${io ? "" : Object.keys(preset).length ? D(" (applying the answers you passed)") : D(" (no terminal — taking the defaults)")}`);
|
|
280
282
|
try {
|
|
281
283
|
const r = await runGuide(io, { cfg, project }, {
|
|
282
284
|
publish: async (p) => {
|
|
@@ -289,7 +291,25 @@ async function guidedSetup(cfg, project, opts = {}) {
|
|
|
289
291
|
c.close();
|
|
290
292
|
}
|
|
291
293
|
},
|
|
292
|
-
|
|
294
|
+
...(project ? {
|
|
295
|
+
setRepoBase: async (base) => {
|
|
296
|
+
const info = repoInfo(project);
|
|
297
|
+
if (!info)
|
|
298
|
+
throw new Error("not inside a git repo");
|
|
299
|
+
const { repo: _r, repoOrigin: _o, ...plain } = cfg;
|
|
300
|
+
const c = new PantheonClient(plain, undefined, { role: "cli" });
|
|
301
|
+
try {
|
|
302
|
+
await c.connect();
|
|
303
|
+
await c.bindRepo(info.fingerprint, info.origin, base);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
c.close();
|
|
307
|
+
}
|
|
308
|
+
if (cfg.spaceId)
|
|
309
|
+
bindRepo(info, cfg.spaceId);
|
|
310
|
+
},
|
|
311
|
+
} : {}),
|
|
312
|
+
}, preset);
|
|
293
313
|
console.error("");
|
|
294
314
|
for (const l of r.applied)
|
|
295
315
|
console.error(` ${C("✓")} ${l}`);
|
|
@@ -301,6 +321,15 @@ async function guidedSetup(cfg, project, opts = {}) {
|
|
|
301
321
|
rl?.close();
|
|
302
322
|
}
|
|
303
323
|
}
|
|
324
|
+
function workerMidRun() {
|
|
325
|
+
try {
|
|
326
|
+
const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
|
|
327
|
+
return !!s.at && Date.now() - s.at < 10 * 60_000 && /run/i.test(String(s.state ?? ""));
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
304
333
|
function printReady(tools) {
|
|
305
334
|
const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
|
|
306
335
|
console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
|
|
@@ -389,6 +418,7 @@ ${B("control")}
|
|
|
389
418
|
pantheon doctor check this machine end to end
|
|
390
419
|
pantheon setup the guided choices: who may wake this machine, what a run may execute,
|
|
391
420
|
what you personally decide, which alerts you want — re-run any time
|
|
421
|
+
(--questions prints them for an agent to ask you; answers go back as flags)
|
|
392
422
|
pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
|
|
393
423
|
|
|
394
424
|
${D("more: pantheon help --all")}
|
|
@@ -396,7 +426,7 @@ ${D("more: pantheon help --all")}
|
|
|
396
426
|
const HELP_ALL = HELP + `
|
|
397
427
|
${B("less common")}
|
|
398
428
|
pantheon relay run only the relay, in this terminal
|
|
399
|
-
pantheon service install [relay|worker|notifier|menubar] | uninstall
|
|
429
|
+
pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
|
|
400
430
|
run a long-lived component in the background, from login
|
|
401
431
|
pantheon members the full list, with key fingerprints
|
|
402
432
|
pantheon whoami your name and fingerprint
|
|
@@ -461,6 +491,8 @@ async function main() {
|
|
|
461
491
|
console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
|
|
462
492
|
console.error(D(` Tell them to run it from their project's git repo. Single use, expires in ${Math.round(Number(flag("ttl", "1440")) / 60)}h.`));
|
|
463
493
|
console.error(D(` Redeeming it admits them — no separate approval step. Need another? \`pantheon invite\``));
|
|
494
|
+
console.error(D(`\n Not technical? Forward them this instead, and their agent does the rest:`));
|
|
495
|
+
console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${r.invite}`)}`);
|
|
464
496
|
}
|
|
465
497
|
if (!r.reach.shareable && r.reach.hint) {
|
|
466
498
|
console.error(`\n\x1b[33m That address only works on this machine.\x1b[0m`);
|
|
@@ -485,6 +517,8 @@ async function main() {
|
|
|
485
517
|
const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
|
|
486
518
|
console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
|
|
487
519
|
console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
|
|
520
|
+
console.error(D(`For a teammate who is not technical, forward this and their agent does the rest:`));
|
|
521
|
+
console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`)}`);
|
|
488
522
|
client.close();
|
|
489
523
|
return;
|
|
490
524
|
}
|
|
@@ -654,20 +688,55 @@ async function main() {
|
|
|
654
688
|
return;
|
|
655
689
|
}
|
|
656
690
|
console.error(`\n${B(`upgraded ${r.from} → ${r.to}`)}`);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
691
|
+
const { restartService, serviceInstalled } = await import("./service.js");
|
|
692
|
+
console.error("");
|
|
693
|
+
if (process.platform === "darwin") {
|
|
694
|
+
const { trayStale, ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
|
|
695
|
+
const { notifierStale, ensureNativeNotifier } = await import("./notifier.js");
|
|
696
|
+
for (const [stale, build, what] of [[trayStale, () => { ensureTrayBinary(); writeTrayCommands(); }, "menu bar"], [notifierStale, ensureNativeNotifier, "alerts"]]) {
|
|
697
|
+
if (!stale())
|
|
698
|
+
continue;
|
|
699
|
+
try {
|
|
700
|
+
build();
|
|
701
|
+
console.error(` ${what}: rebuilt from this release's source`);
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
console.error(` ${what}: could not rebuild (${e.message.split("\n")[0]})`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (const kind of ["worker", "notifier", "menubar"]) {
|
|
709
|
+
if (!serviceInstalled(kind))
|
|
710
|
+
continue;
|
|
711
|
+
if (kind === "worker" && workerMidRun()) {
|
|
712
|
+
console.error(` ${D("worker: a run is in progress — left alone. When it is done:")} pantheon service restart worker`);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
for (const l of restartService(kind))
|
|
716
|
+
console.error(l);
|
|
717
|
+
}
|
|
718
|
+
if (r.restart.includes("relay"))
|
|
719
|
+
console.error(` relay: ${D("shared with your teammates, so it is yours to time:")} launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay`);
|
|
720
|
+
const cfgNow = load();
|
|
721
|
+
if (cfgNow) {
|
|
722
|
+
if (loadProfile(cfgNow).agent.wake_default === undefined)
|
|
723
|
+
await guidedSetup(cfgNow, repoInfo(process.cwd())?.root ?? null);
|
|
663
724
|
else
|
|
664
|
-
console.error(
|
|
725
|
+
console.error(D(`\n Your setup answers still apply — pantheon setup to change them.`));
|
|
665
726
|
}
|
|
727
|
+
console.error(`\n${B("One thing only you can do:")} quit and reopen your agent windows so they load the new pantheon.`);
|
|
666
728
|
return;
|
|
667
729
|
}
|
|
668
730
|
case "setup": {
|
|
731
|
+
if (argv.includes("--questions")) {
|
|
732
|
+
console.log(questionnaire());
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const { preset, errors } = parseSetupFlags(argv);
|
|
736
|
+
if (errors.length)
|
|
737
|
+
die(errors.join("\n") + "\n pantheon setup --questions lists every answer");
|
|
669
738
|
const cfg = need();
|
|
670
|
-
await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true });
|
|
739
|
+
await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true, preset });
|
|
671
740
|
return;
|
|
672
741
|
}
|
|
673
742
|
case "version": {
|
|
@@ -2111,6 +2180,12 @@ async function main() {
|
|
|
2111
2180
|
console.error(l);
|
|
2112
2181
|
return;
|
|
2113
2182
|
}
|
|
2183
|
+
if (sub === "restart") {
|
|
2184
|
+
const { restartService } = await import("./service.js");
|
|
2185
|
+
for (const l of restartService(kind))
|
|
2186
|
+
console.error(l);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2114
2189
|
if (kind === "relay") {
|
|
2115
2190
|
const data = resolveDataDir(flag("data")).dir;
|
|
2116
2191
|
const port = flag("port", "8787");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
export const helperHashPath = (binary) => `${binary}.source.sha256`;
|
|
4
|
+
export function sourceHash(source) {
|
|
5
|
+
return createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function recordHelperBuild(binary, source) {
|
|
8
|
+
try {
|
|
9
|
+
writeFileSync(helperHashPath(binary), sourceHash(source) + "\n");
|
|
10
|
+
}
|
|
11
|
+
catch { }
|
|
12
|
+
}
|
|
13
|
+
export function helperStale(binary, source) {
|
|
14
|
+
if (!existsSync(binary) || !existsSync(source))
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(helperHashPath(binary), "utf8").trim() !== sourceHash(source);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=helpers-build.js.map
|
package/dist/notifier.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { helperStale, recordHelperBuild } from "./helpers-build.js";
|
|
2
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { platform } from "node:os";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -56,8 +57,10 @@ export function ensureNativeNotifier(home = CONFIG_DIR) {
|
|
|
56
57
|
requireDeveloperTools("notifications", "notifier");
|
|
57
58
|
execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
|
|
58
59
|
chmodSync(binary, 0o755);
|
|
60
|
+
recordHelperBuild(binary, source);
|
|
59
61
|
return binary;
|
|
60
62
|
}
|
|
63
|
+
export function notifierStale(home = CONFIG_DIR) { return helperStale(hudBinaryPath(home), hudSourcePath()); }
|
|
61
64
|
export function requireDeveloperTools(what, service) {
|
|
62
65
|
try {
|
|
63
66
|
execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
|
package/dist/relay.js
CHANGED
|
@@ -145,8 +145,12 @@ export class Relay {
|
|
|
145
145
|
this.adminAllow = (process.env.PANTHEON_ADMIN_ALLOW_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
146
146
|
const wantStore = this.requireAccounts || this.adminToken !== null;
|
|
147
147
|
this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
|
|
148
|
-
if (this.accounts)
|
|
148
|
+
if (this.accounts) {
|
|
149
149
|
this.backfillSpaceOwners();
|
|
150
|
+
const closed = this.accounts.endAllOpenSessions();
|
|
151
|
+
if (closed)
|
|
152
|
+
this.log(`ended ${closed} session${closed === 1 ? "" : "s"} left open by the previous relay process`);
|
|
153
|
+
}
|
|
150
154
|
this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
|
|
151
155
|
const envNum = (k) => {
|
|
152
156
|
const v = process.env[k];
|
|
@@ -925,7 +929,8 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
|
|
|
925
929
|
const kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
926
930
|
const accounts = A.allAccounts();
|
|
927
931
|
const devices = A.allDevices();
|
|
928
|
-
const sessions = A.
|
|
932
|
+
const sessions = A.liveSessions(200);
|
|
933
|
+
const endedToday = A.endedSessionsSince(Date.now() - DAY);
|
|
929
934
|
const audit = A.auditLog({ limit: 300 });
|
|
930
935
|
const blocks = A.allBlocks();
|
|
931
936
|
const verifs = A.recentVerifications(50);
|
|
@@ -970,7 +975,7 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
970
975
|
<div class=stat><b>${c.accounts}</b><span>accounts</span></div>
|
|
971
976
|
<div class=stat><b>${c.devices}</b><span>devices</span></div>
|
|
972
977
|
<div class=stat><b>${Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0)}<span class=dim style="font-size:14px"> / ${c.accounts}</span></b><span>paid plans / accounts</span></div>
|
|
973
|
-
<div class=stat><b>${sessions.filter((s) =>
|
|
978
|
+
<div class=stat><b>${sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>connections heartbeating / open</span></div>
|
|
974
979
|
<div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
|
|
975
980
|
<div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
|
|
976
981
|
<div class=stat><b>${snapshots.length}</b><span>snapshots · latest ${snapshots.length ? esc(snapshots[snapshots.length - 1].replace(/^accounts-|\\.db$/g, "").replace(/T/, " ").slice(0, 16)) : "—"}</span></div>
|
|
@@ -1013,10 +1018,11 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
|
|
|
1013
1018
|
<p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward "people per space" so nobody mints five at once. Personas are files on the user's machine and cannot be limited here. Every change is audited.</p>
|
|
1014
1019
|
|
|
1015
1020
|
<h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
|
|
1016
|
-
<tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
1017
|
-
${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
|
|
1018
|
-
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=
|
|
1021
|
+
<tr><th>session</th><th>account</th><th>device</th><th>role</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
|
|
1022
|
+
${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
|
|
1023
|
+
${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}
|
|
1019
1024
|
</table></div>
|
|
1025
|
+
<p class=note>One row per open connection — an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes; ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours. "Stale" means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
|
|
1020
1026
|
|
|
1021
1027
|
<h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
|
|
1022
1028
|
<tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
|
|
@@ -1250,12 +1256,20 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1250
1256
|
let space = null;
|
|
1251
1257
|
let me = null;
|
|
1252
1258
|
let acct = null;
|
|
1259
|
+
let dbSession = null;
|
|
1260
|
+
let lastTouch = 0;
|
|
1261
|
+
const touch = () => {
|
|
1262
|
+
if (dbSession && Date.now() - lastTouch > 60_000) {
|
|
1263
|
+
lastTouch = Date.now();
|
|
1264
|
+
this.accounts?.touchSession(dbSession);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1253
1267
|
let sessionId = "";
|
|
1254
1268
|
let role = "interactive";
|
|
1255
1269
|
let lastMsg = "";
|
|
1256
1270
|
let alive = true;
|
|
1257
1271
|
const nonce = newNonce();
|
|
1258
|
-
ws.on("pong", () => { alive = true; });
|
|
1272
|
+
ws.on("pong", () => { alive = true; touch(); });
|
|
1259
1273
|
const probe = ws;
|
|
1260
1274
|
probe._alive = () => alive;
|
|
1261
1275
|
probe._clear = () => { alive = false; };
|
|
@@ -1303,6 +1317,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1303
1317
|
}
|
|
1304
1318
|
const m = parsed.data;
|
|
1305
1319
|
lastMsg = m.t === "act" ? m.act : m.t;
|
|
1320
|
+
touch();
|
|
1306
1321
|
if (m.t === "ping")
|
|
1307
1322
|
return this.send(ws, { t: "pong" });
|
|
1308
1323
|
if (m.t === "hello") {
|
|
@@ -1370,7 +1385,9 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1370
1385
|
}
|
|
1371
1386
|
m.member = auth.account.username;
|
|
1372
1387
|
acct = auth.account;
|
|
1373
|
-
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
|
|
1388
|
+
acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
|
|
1389
|
+
dbSession = acctSession;
|
|
1390
|
+
lastTouch = Date.now();
|
|
1374
1391
|
}
|
|
1375
1392
|
let enrolled = null;
|
|
1376
1393
|
if (m.enroll) {
|
|
@@ -1702,6 +1719,10 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1702
1719
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
|
|
1703
1720
|
});
|
|
1704
1721
|
ws.on("close", () => {
|
|
1722
|
+
if (dbSession) {
|
|
1723
|
+
this.accounts?.endSession(dbSession);
|
|
1724
|
+
dbSession = null;
|
|
1725
|
+
}
|
|
1705
1726
|
if (!space || !me)
|
|
1706
1727
|
return;
|
|
1707
1728
|
me.sessions.delete(sessionId);
|
|
@@ -1753,7 +1774,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
1753
1774
|
message: `over ${LIMITS.rateActs} acts in ${LIMITS.rateWindowMs / 1000}s. This is a backstop against a stuck loop, not a hard stop — wait a few seconds and send again. If you are genuinely producing this much, you are probably doing work that belongs in a commit and a DELIVERED, not in acts.` };
|
|
1754
1775
|
}
|
|
1755
1776
|
for (const id of to ?? []) {
|
|
1756
|
-
if (!sp.members.has(id)) {
|
|
1777
|
+
if (!sp.members.has(id) && !sp.allow.get(id)?.admitted) {
|
|
1757
1778
|
return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
|
|
1758
1779
|
}
|
|
1759
1780
|
}
|
|
@@ -2190,6 +2211,11 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2190
2211
|
lastTouch: Date.now(),
|
|
2191
2212
|
};
|
|
2192
2213
|
this.spaces.set(name, sp);
|
|
2214
|
+
for (const e of allow.all()) {
|
|
2215
|
+
if (e.admitted && !sp.members.has(e.member)) {
|
|
2216
|
+
sp.members.set(e.member, { id: e.member, display: e.member, agent: "unknown", last_seen: e.first_seen, sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] });
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2193
2219
|
const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
|
|
2194
2220
|
this.log(`[${name}] space ready (seq ${sp.ledger.head}, ${sp.goals.size} goals — ${open} still open, ${sp.obligations.size} obligations restored)`);
|
|
2195
2221
|
this.compactSpace(sp);
|
package/dist/service.js
CHANGED
|
@@ -138,6 +138,20 @@ export function uninstallService(kind) {
|
|
|
138
138
|
}
|
|
139
139
|
return out;
|
|
140
140
|
}
|
|
141
|
+
export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
|
|
142
|
+
if (!serviceInstalled(kind))
|
|
143
|
+
return [` ${kind}: not installed here`];
|
|
144
|
+
try {
|
|
145
|
+
if (platform() === "darwin")
|
|
146
|
+
exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 501}/${LABEL(kind)}`]);
|
|
147
|
+
else
|
|
148
|
+
exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
|
|
149
|
+
return [` ${kind}: restarted on the new code`];
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
141
155
|
export function serviceInstalled(kind) {
|
|
142
156
|
try {
|
|
143
157
|
return existsSync(platform() === "darwin" ? plistPath(kind) : unitPath(kind));
|
package/dist/setup-guide.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { applyProfileInit, loadProfile, profilePath, saveProfile } from "./profile.js";
|
|
@@ -39,14 +40,110 @@ export function guideDefaults(profile, cfg) {
|
|
|
39
40
|
persona: false,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
|
-
export
|
|
43
|
+
export function detectBaseBranch(project) {
|
|
44
|
+
const git = (...a) => { try {
|
|
45
|
+
return execFileSync("git", a, { cwd: project, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "";
|
|
49
|
+
} };
|
|
50
|
+
if (!git("remote", "get-url", "origin"))
|
|
51
|
+
return null;
|
|
52
|
+
const head = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD");
|
|
53
|
+
if (head)
|
|
54
|
+
return head.replace(/^origin\//, "");
|
|
55
|
+
const cur = git("rev-parse", "--abbrev-ref", "HEAD");
|
|
56
|
+
return cur && cur !== "HEAD" ? cur : null;
|
|
57
|
+
}
|
|
58
|
+
export function questionnaire() {
|
|
59
|
+
const opt = (flag, id, label) => ` ${label} → --${flag} ${id}`;
|
|
60
|
+
return [
|
|
61
|
+
"pantheon setup — the questions, and the flag for each answer.",
|
|
62
|
+
"Ask your human each one in plain words, then run: pantheon setup --yes <flags>",
|
|
63
|
+
"",
|
|
64
|
+
"1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
|
|
65
|
+
" in a separate copy of the repo, and delivers on a branch for review)",
|
|
66
|
+
...WAKE_OPTIONS.map((o) => opt("wake", o.id, o.label)),
|
|
67
|
+
"2. When a teammate's request wakes this machine, what may the run do? (skip if --wake no)",
|
|
68
|
+
opt("commands", "no", "Edit files only — it can answer and draft, but cannot run tests, commit, or push."),
|
|
69
|
+
opt("commands", "yes", "Run commands too — needed to run tests and to commit and push work for review."),
|
|
70
|
+
"3. Their name, as teammates will see it. → --name \"…\"",
|
|
71
|
+
`4. Their role, in a word (${TITLE_OPTIONS.slice(0, -1).join(", ")}, or their own). → --title \"…\"`,
|
|
72
|
+
"5. Which of these do they decide personally? Agents will stop and ask them first. Any number, or none.",
|
|
73
|
+
...DECISION_AREAS.map((x) => ` ${x.label} → ${x.id}`),
|
|
74
|
+
" pass them comma-separated: --decides deployment,pricing (or --decides none)",
|
|
75
|
+
"6. Which desktop alerts do they want?",
|
|
76
|
+
...ALERT_OPTIONS.map((o) => opt("alerts", o.id, o.label)),
|
|
77
|
+
"7. Should their worker follow a short written playbook for this repo? (skip if --wake no)",
|
|
78
|
+
opt("playbook", "yes", "Yes — write a starter playbook now (recommended)"),
|
|
79
|
+
opt("playbook", "no", "Not now"),
|
|
80
|
+
"8. Which branch should workers start from, and deliveries be checked against? Usually the branch this",
|
|
81
|
+
" project deploys or releases from (often the default branch). → --base <branch>",
|
|
82
|
+
"",
|
|
83
|
+
"Example: pantheon setup --yes --wake anyone --commands yes --name \"Praneet Sinha\" --title Founder \\",
|
|
84
|
+
" --decides deployment --alerts decisions --playbook yes --base main",
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
export function parseSetupFlags(argv) {
|
|
88
|
+
const preset = {};
|
|
89
|
+
const errors = [];
|
|
90
|
+
const val = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 ? argv[i + 1] : undefined; };
|
|
91
|
+
const yn = (name) => { const v = val(name); if (v === undefined)
|
|
92
|
+
return undefined; if (/^(yes|true|on|1)$/i.test(v))
|
|
93
|
+
return true; if (/^(no|false|off|0)$/i.test(v))
|
|
94
|
+
return false; errors.push(`--${name} takes yes or no, not "${v}"`); return undefined; };
|
|
95
|
+
const wake = val("wake");
|
|
96
|
+
if (wake !== undefined) {
|
|
97
|
+
if (WAKE_OPTIONS.some((o) => o.id === wake))
|
|
98
|
+
preset.wake = wake;
|
|
99
|
+
else
|
|
100
|
+
errors.push(`--wake takes ${WAKE_OPTIONS.map((o) => o.id).join(", ")}, not "${wake}"`);
|
|
101
|
+
}
|
|
102
|
+
const commands = yn("commands");
|
|
103
|
+
if (commands !== undefined)
|
|
104
|
+
preset.commands = commands;
|
|
105
|
+
const name = val("name");
|
|
106
|
+
if (name !== undefined)
|
|
107
|
+
preset.name = name.trim();
|
|
108
|
+
const title = val("title");
|
|
109
|
+
if (title !== undefined)
|
|
110
|
+
preset.title = title.trim();
|
|
111
|
+
const decides = val("decides");
|
|
112
|
+
if (decides !== undefined) {
|
|
113
|
+
const ids = decides.split(",").map((x) => x.trim().toLowerCase()).filter((x) => x && x !== "none");
|
|
114
|
+
const bad = ids.filter((id) => !DECISION_AREAS.some((x) => x.id === id));
|
|
115
|
+
if (bad.length)
|
|
116
|
+
errors.push(`--decides: unknown area${bad.length === 1 ? "" : "s"} ${bad.join(", ")} — use ${DECISION_AREAS.map((x) => x.id).join(", ")} or none`);
|
|
117
|
+
else
|
|
118
|
+
preset.decides = [...new Set(ids)];
|
|
119
|
+
}
|
|
120
|
+
const alerts = val("alerts");
|
|
121
|
+
if (alerts !== undefined) {
|
|
122
|
+
if (ALERT_OPTIONS.some((o) => o.id === alerts))
|
|
123
|
+
preset.notifications = alerts;
|
|
124
|
+
else
|
|
125
|
+
errors.push(`--alerts takes ${ALERT_OPTIONS.map((o) => o.id).join(", ")}, not "${alerts}"`);
|
|
126
|
+
}
|
|
127
|
+
const playbook = yn("playbook");
|
|
128
|
+
if (playbook !== undefined)
|
|
129
|
+
preset.persona = playbook;
|
|
130
|
+
const base = val("base");
|
|
131
|
+
if (base !== undefined && base.trim())
|
|
132
|
+
preset.base = base.trim();
|
|
133
|
+
return { preset, errors };
|
|
134
|
+
}
|
|
135
|
+
export async function runGuide(io, ctx, deps = {}, preset = {}) {
|
|
43
136
|
const install = deps.installService ?? realInstall;
|
|
44
137
|
const uninstall = deps.uninstallService ?? realUninstall;
|
|
45
138
|
const installed = deps.serviceInstalled ?? realInstalled;
|
|
46
139
|
const file = deps.profileFile ?? profilePath();
|
|
47
140
|
const profile = loadProfile(ctx.cfg, file);
|
|
48
|
-
const d = guideDefaults(profile, ctx.cfg);
|
|
141
|
+
const d = { ...guideDefaults(profile, ctx.cfg), ...preset };
|
|
49
142
|
const a = { ...d };
|
|
143
|
+
if (a.wake === "no") {
|
|
144
|
+
a.commands = false;
|
|
145
|
+
a.persona = false;
|
|
146
|
+
}
|
|
50
147
|
const applied = [], warnings = [];
|
|
51
148
|
if (io) {
|
|
52
149
|
io.say("A few choices only you can make. Pick a number; Enter keeps the suggested one.\n");
|
|
@@ -72,6 +169,12 @@ export async function runGuide(io, ctx, deps = {}) {
|
|
|
72
169
|
const p = await io.choose("Should your worker follow a short written playbook for this repo? It says what a woken run may do here and what it must leave to you. You can edit it any time.", ["Yes — write a starter playbook now (recommended)", "Not now"], 0);
|
|
73
170
|
a.persona = p === 0;
|
|
74
171
|
}
|
|
172
|
+
const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
|
|
173
|
+
if (detected) {
|
|
174
|
+
const suggested = preset.base ?? detected;
|
|
175
|
+
const b = await io.choose("Which branch should your worker start from, and deliveries be checked against? Usually the branch this project deploys or releases from.", [`${suggested}${suggested === detected ? " (what origin calls its default)" : ""}`, "A different branch"], 0);
|
|
176
|
+
a.base = b === 0 ? suggested : ((await io.ask("Branch name", suggested)).trim() || suggested);
|
|
177
|
+
}
|
|
75
178
|
}
|
|
76
179
|
let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
|
|
77
180
|
const authority = { ...next.authority };
|
|
@@ -169,6 +272,15 @@ export async function runGuide(io, ctx, deps = {}) {
|
|
|
169
272
|
applied.push(`playbook written: ${personaFile} — commit it so every machine on this repo follows it`);
|
|
170
273
|
}
|
|
171
274
|
}
|
|
275
|
+
if (a.base && deps.setRepoBase) {
|
|
276
|
+
try {
|
|
277
|
+
await deps.setRepoBase(a.base);
|
|
278
|
+
applied.push(`workers on this repo cut from ${a.base}, and deliveries are checked against it`);
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
warnings.push(`could not register the base branch with the team (${e.message}) — later: pantheon repo add --base ${a.base}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
172
284
|
return { answers: a, applied, warnings, ...(personaFile ? { personaFile } : {}) };
|
|
173
285
|
}
|
|
174
286
|
export function starterPersona(a) {
|
package/dist/tray.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { helperStale, recordHelperBuild } from "./helpers-build.js";
|
|
2
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { platform } from "node:os";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -26,8 +27,10 @@ export function ensureTrayBinary(home = CONFIG_DIR) {
|
|
|
26
27
|
requireDeveloperTools("the menu bar", "menubar");
|
|
27
28
|
execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
|
|
28
29
|
chmodSync(binary, 0o755);
|
|
30
|
+
recordHelperBuild(binary, source);
|
|
29
31
|
return binary;
|
|
30
32
|
}
|
|
33
|
+
export function trayStale(home = CONFIG_DIR) { return helperStale(trayBinaryPath(home), traySourcePath()); }
|
|
31
34
|
export function writeTrayCommands(home = CONFIG_DIR) {
|
|
32
35
|
const bin = whichPantheon() ?? "pantheon";
|
|
33
36
|
const dir = join(home, "bin");
|
package/dist/upgrade.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { installForUser } from "./install.js";
|
|
6
|
-
import { relayProc, workerProc } from "./machine.js";
|
|
6
|
+
import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
|
|
7
7
|
export function repoRoot() {
|
|
8
8
|
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
9
|
}
|
|
@@ -42,6 +42,16 @@ export function defaultDeps() {
|
|
|
42
42
|
up.push("worker");
|
|
43
43
|
}
|
|
44
44
|
catch { }
|
|
45
|
+
try {
|
|
46
|
+
if (notifierProc().running)
|
|
47
|
+
up.push("notifier");
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
try {
|
|
51
|
+
if (menubarProc().running)
|
|
52
|
+
up.push("menubar");
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
45
55
|
return up;
|
|
46
56
|
},
|
|
47
57
|
npmUpgrade: () => execFileSync("npm", ["install", "-g", `${PKG_NAME}@latest`, "--no-audit", "--no-fund"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
|
|
@@ -118,21 +118,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
///
|
|
124
|
-
///
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
121
|
+
enum MarkMode { case full, idle, off }
|
|
122
|
+
|
|
123
|
+
/// The Three Stones mark — the product's own logo, the same three arcs the
|
|
124
|
+
/// website draws: a lintel across the top and a gate at the bottom around a
|
|
125
|
+
/// centre nobody owns. Drawn with paths rather than loaded from an asset, so it
|
|
126
|
+
/// is a crisp template image at any menu bar scale and takes the system tint.
|
|
127
|
+
/// State rides on its weight: full when peers can wake work here, lighter when
|
|
128
|
+
/// connected but nothing unattended will run, faint with a slash when the relay
|
|
129
|
+
/// is unreachable or this machine is not in a space. Never rotate it.
|
|
130
|
+
private func markImage(_ mode: MarkMode) -> NSImage {
|
|
131
|
+
let img = NSImage(size: NSSize(width: 18, height: 18), flipped: false) { rect in
|
|
132
|
+
let s = rect.width / 24.0 // the mark is authored on a 24-unit grid
|
|
133
|
+
let c = NSPoint(x: rect.midX, y: rect.midY)
|
|
134
|
+
let r = 8.9 * s
|
|
135
|
+
let stones = NSBezierPath()
|
|
136
|
+
stones.lineWidth = 3.4 * s
|
|
137
|
+
stones.lineCapStyle = .round
|
|
138
|
+
for (a, b) in [(51.0, 129.0), (171.0, 249.0), (291.0, 369.0)] { // lintel, left stone, right stone
|
|
139
|
+
let rad = a * .pi / 180
|
|
140
|
+
stones.move(to: NSPoint(x: c.x + r * cos(rad), y: c.y + r * sin(rad)))
|
|
141
|
+
stones.appendArc(withCenter: c, radius: r, startAngle: a, endAngle: b, clockwise: false)
|
|
142
|
+
}
|
|
143
|
+
let alpha: CGFloat = mode == .full ? 1.0 : mode == .idle ? 0.58 : 0.32
|
|
144
|
+
NSColor.black.withAlphaComponent(alpha).setStroke()
|
|
145
|
+
stones.stroke()
|
|
146
|
+
if mode == .off {
|
|
147
|
+
let slash = NSBezierPath()
|
|
148
|
+
slash.lineWidth = 2.2 * s
|
|
149
|
+
slash.lineCapStyle = .round
|
|
150
|
+
slash.move(to: NSPoint(x: rect.minX + 4 * s, y: rect.minY + 4 * s))
|
|
151
|
+
slash.line(to: NSPoint(x: rect.maxX - 4 * s, y: rect.maxY - 4 * s))
|
|
152
|
+
NSColor.black.setStroke()
|
|
153
|
+
slash.stroke()
|
|
154
|
+
}
|
|
155
|
+
return true
|
|
135
156
|
}
|
|
157
|
+
img.isTemplate = true
|
|
158
|
+
img.accessibilityDescription = "pantheon"
|
|
159
|
+
return img
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// The glyph is the product's one-word status; a count rides alongside when
|
|
163
|
+
/// decisions wait on the human — the badge is the point.
|
|
164
|
+
private func paint() {
|
|
165
|
+
let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
|
|
166
|
+
item.button?.image = markImage(mode)
|
|
167
|
+
item.button?.title = state.decisions.isEmpty ? "" : " \(state.decisions.count)"
|
|
168
|
+
item.button?.imagePosition = .imageLeading
|
|
136
169
|
item.button?.toolTip = state.configured
|
|
137
170
|
? "pantheon — \(state.member) in \(state.space)\(state.relayOk ? "" : " (relay unreachable)")"
|
|
138
171
|
: "pantheon — not joined to a space yet"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
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": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"relay": "node dist/cli.js relay",
|
|
16
16
|
"test": "PANTHEON_HOME=\"$(mktemp -d)\" node --test --test-force-exit dist/test/*.test.js",
|
|
17
17
|
"prepare": "npm run build",
|
|
18
|
-
"prepublishOnly": "npm run build &&
|
|
18
|
+
"prepublishOnly": "npm run build && npm test",
|
|
19
19
|
"start": "node dist/cli.js relay"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|