convene-cli 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.assessWatchHealth = assessWatchHealth;
6
7
  exports.login = login;
7
8
  exports.whoami = whoami;
8
9
  exports.assessLaneIdentity = assessLaneIdentity;
@@ -54,6 +55,41 @@ function relaunchWatch(slug) {
54
55
  return false;
55
56
  }
56
57
  }
58
+ /**
59
+ * PURE health verdict for the `doctor` "watch" line (unit-tested, no I/O). The
60
+ * watcher is a BEST-EFFORT liveness/notify daemon: it self-terminates once its
61
+ * owning session goes idle (~30m — see watch.ts) and is only relaunched at the
62
+ * next SessionStart, so a stale/absent heartbeat is the EXPECTED state for any
63
+ * quiet or long-lived session, NOT an error. Directed halts still surface via the
64
+ * pull path (fetch / session-open) and are still BLOCKED by the guard (live
65
+ * lane-state) whether or not this daemon is up. The check is therefore
66
+ * informational (never fails doctor); the verdict only drives the human-readable
67
+ * detail and whether `--fix` offers a relaunch.
68
+ * - alive — heartbeat fresh (age ≤ staleSec).
69
+ * - wedged — stale heartbeat but the pidfile owner is STILL ALIVE: a live process
70
+ * that has stopped stamping (the one genuinely odd state).
71
+ * - idle — stale/absent heartbeat and no live watcher: simply not running.
72
+ */
73
+ function assessWatchHealth(args) {
74
+ const { ageSec, staleSec, hasLiveWatcher } = args;
75
+ if (ageSec != null && ageSec <= staleSec)
76
+ return 'alive';
77
+ if (hasLiveWatcher)
78
+ return 'wedged';
79
+ return 'idle';
80
+ }
81
+ /** The `doctor` "watch" detail line for a health state (age in seconds, or null). */
82
+ function watchDetail(state, age) {
83
+ switch (state) {
84
+ case 'alive':
85
+ return `halt watcher alive (heartbeat ${age}s ago)`;
86
+ case 'wedged':
87
+ return `halt watcher process up but not heartbeating (${age}s) — restart the session if mid-turn halt pings stop`;
88
+ case 'idle':
89
+ default:
90
+ return 'halt watcher not running — relaunches at next session start (directed halts still surface via the pull path + the guard; `convene doctor --fix` starts one now)';
91
+ }
92
+ }
57
93
  function readStdin() {
58
94
  try {
59
95
  return node_fs_1.default.readFileSync(0, 'utf8').trim();
@@ -450,33 +486,54 @@ async function doctor(opts) {
450
486
  // 6b. settings non-destructiveness — Convene's edits stay additive + marker-scoped;
451
487
  // user-owned hooks/files are never clobbered. Fails only on genuine corruption.
452
488
  checks.push(assessSettingsIntegrity(top));
453
- // 7. watch heartbeata stale/absent heartbeat means the mid-task halt watcher
454
- // is DOWN (so directed halts won't surface between turns). Only meaningful for a
455
- // repo on the bus; --fix may (re)launch `convene watch` detached.
489
+ // 6c. coordination-hook driftthe committed project .claude/settings.json should
490
+ // carry the canonical COORD_HOOKS set. The writer (`convene init`) and this
491
+ // check share ONE source of truth (../hook), so a hook added to COORD_HOOKS
492
+ // (e.g. PR #55's Stop `convene wrap`) can't silently go unwired in a repo
493
+ // onboarded earlier. Verbs THIS binary lacks are skipped. ADVISORY (ok:true):
494
+ // drift is a nudge, not a hard failure — it must not fail doctor fleet-wide.
495
+ if (proj?.slug && top) {
496
+ const projSettings = (0, hook_1.parseSettings)((0, hook_1.readSettingsRaw)((0, hook_1.projectSettingsPath)(top)));
497
+ if (projSettings != null) {
498
+ const missing = (0, hook_1.missingCoordHooks)(projSettings);
499
+ checks.push({
500
+ name: 'coord-hooks',
501
+ ok: true,
502
+ detail: missing.length === 0
503
+ ? 'committed coordination hooks match the canonical set'
504
+ : `drifted — committed settings missing ${missing
505
+ .map((h) => `${h.event} \`${h.command}\``)
506
+ .join(', ')}; run \`convene init --refresh-docs\` to wire`,
507
+ });
508
+ }
509
+ }
510
+ // 7. watch heartbeat — the mid-task halt watcher is a BEST-EFFORT liveness/notify
511
+ // daemon that self-exits when its session goes idle and only relaunches at the
512
+ // next SessionStart, so a stale/absent heartbeat is EXPECTED for a quiet or
513
+ // long-lived session — NOT a failure. Directed halts surface via the pull path
514
+ // and are blocked by the guard regardless of this daemon. Informational only
515
+ // (never fails doctor); --fix offers a best-effort relaunch. See assessWatchHealth.
456
516
  if (proj?.slug) {
457
- let age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
458
- let watchOk = age != null && age <= WATCH_STALE_SEC;
459
- if (!watchOk && opts.fix) {
460
- if (relaunchWatch(proj.slug)) {
517
+ const slug = proj.slug;
518
+ const watcherAlive = () => {
519
+ const p = (0, cache_1.readWatchPid)(slug);
520
+ return !!(p && (0, cache_1.isPidAlive)(p.pid));
521
+ };
522
+ let age = (0, cache_1.watchHeartbeatAgeSec)(slug);
523
+ let state = assessWatchHealth({ ageSec: age, staleSec: WATCH_STALE_SEC, hasLiveWatcher: watcherAlive() });
524
+ if (state !== 'alive' && opts.fix) {
525
+ if (relaunchWatch(slug)) {
461
526
  // Give the freshly-launched daemon a beat to stamp its first heartbeat.
462
527
  const until = Date.now() + 2500;
463
528
  while (Date.now() < until) {
464
- age = (0, cache_1.watchHeartbeatAgeSec)(proj.slug);
529
+ age = (0, cache_1.watchHeartbeatAgeSec)(slug);
465
530
  if (age != null && age <= WATCH_STALE_SEC)
466
531
  break;
467
532
  }
468
- watchOk = age != null && age <= WATCH_STALE_SEC;
533
+ state = assessWatchHealth({ ageSec: age, staleSec: WATCH_STALE_SEC, hasLiveWatcher: watcherAlive() });
469
534
  }
470
535
  }
471
- checks.push({
472
- name: 'watch',
473
- ok: watchOk,
474
- detail: watchOk
475
- ? `halt watcher alive (heartbeat ${age}s ago)`
476
- : age == null
477
- ? 'halt watcher DOWN — no heartbeat (run `convene doctor --fix` to relaunch)'
478
- : `halt watcher STALE — heartbeat ${age}s ago (run \`convene doctor --fix\` to relaunch)`,
479
- });
536
+ checks.push({ name: 'watch', ok: true, detail: watchDetail(state, age) });
480
537
  // 7a. reap orphaned watchers — the cleanup half of the daemon-leak fix. Only
481
538
  // under --fix (running `ps` on every doctor would slow the fast path). Runs
482
539
  // AFTER the relaunch + heartbeat-wait above so the freshly-relaunched watcher
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enrollDevice = enrollDevice;
7
+ /**
8
+ * `convene enroll-device` — connect THIS machine to your EXISTING identity without
9
+ * copying a key. Proves email ownership (an emailed confirm link) + device ownership
10
+ * (a CLI-held secret), then the server mints a fresh per-device key that ONLY this
11
+ * CLI can claim. Also auto-invoked by `convene setup`/`join` when the bus reports an
12
+ * identity already exists for your email (EMAIL_TAKEN).
13
+ */
14
+ const node_crypto_1 = __importDefault(require("node:crypto"));
15
+ const node_os_1 = __importDefault(require("node:os"));
16
+ const api_1 = require("../api");
17
+ const config_1 = require("../config");
18
+ const git_1 = require("../git");
19
+ const ctx_1 = require("../ctx");
20
+ const log = (m) => process.stdout.write(m + '\n');
21
+ const sha256 = (s) => node_crypto_1.default.createHash('sha256').update(s, 'utf8').digest('hex');
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ /**
24
+ * Run the device-enrollment flow. Returns true once a fresh key is saved to the
25
+ * local config; on timeout / expiry it prints guidance and exits the process.
26
+ */
27
+ async function enrollDevice(opts) {
28
+ const top = (0, git_1.gitToplevel)();
29
+ const proj = (0, config_1.loadProjectConfig)(top);
30
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
31
+ const baseUrl = (opts.baseUrl || cfg.baseUrl).replace(/\/$/, '');
32
+ const email = opts.email || (0, git_1.gitUserEmail)(top ?? undefined) || undefined;
33
+ if (!email)
34
+ (0, ctx_1.die)('no email to enroll — pass `--email <you@example.com>` (or set git user.email)');
35
+ // CLI-held secret: send only its hash to start; present the raw secret at poll so
36
+ // ONLY this process can claim the minted key (a browser that clicks the link cannot).
37
+ const rawSecret = node_crypto_1.default.randomBytes(32).toString('base64url');
38
+ const secretHash = sha256(rawSecret);
39
+ const label = (opts.label || node_os_1.default.hostname() || 'cli').slice(0, 80);
40
+ const api = new api_1.ConveneApi(baseUrl, null);
41
+ const start = await api.enrollStart({ email: email, secret_hash: secretHash, device_label: label }, 10_000);
42
+ if (!start.ok || !start.json) {
43
+ (0, ctx_1.die)(`could not start device enrollment (${start.status}): ${start.error ?? 'unreachable'}`);
44
+ }
45
+ const { enrollment_id, poll_interval_ms, expires_in_s } = start.json;
46
+ const ttlS = expires_in_s ?? 900;
47
+ const mins = Math.max(1, Math.round(ttlS / 60));
48
+ log('');
49
+ log(`📧 We emailed a confirmation link to ${email}.`);
50
+ log(` Open it and click "Authorize device" to connect this machine (expires in ${mins} min).`);
51
+ log(` Waiting…`);
52
+ const intervalMs = Math.max(1000, poll_interval_ms ?? 2000);
53
+ const deadline = Date.now() + ttlS * 1000;
54
+ while (Date.now() < deadline) {
55
+ await sleep(intervalMs);
56
+ const poll = await api.enrollPoll(enrollment_id, rawSecret, 8000);
57
+ const st = poll.json?.status;
58
+ if (st === 'ok' && poll.json?.api_key) {
59
+ const member = poll.json.member?.handle ?? '';
60
+ (0, config_1.saveFileConfig)({ apiKey: poll.json.api_key, baseUrl, member });
61
+ log('');
62
+ log(`✓ Authorized as ${member}. This machine is connected (config saved 0600).`);
63
+ return true;
64
+ }
65
+ if (st === 'expired') {
66
+ (0, ctx_1.die)('the confirmation link expired or was already used — run `convene setup` again to retry.');
67
+ }
68
+ // 'pending' / 'not_found' → the human hasn't confirmed yet; keep waiting.
69
+ }
70
+ (0, ctx_1.die)('timed out waiting for email confirmation — run `convene setup` again to retry.');
71
+ return false; // unreachable: die() exits.
72
+ }
@@ -11,6 +11,7 @@ exports.explain = explain;
11
11
  * offline agent still gets the essentials. The endpoint is unauthenticated, so
12
12
  * this works even before `convene login`.
13
13
  */
14
+ const node_child_process_1 = require("node:child_process");
14
15
  const config_1 = require("../config");
15
16
  const api_1 = require("../api");
16
17
  const brand_1 = require("../brand");
@@ -45,6 +46,20 @@ async function explain(question) {
45
46
  return;
46
47
  }
47
48
  if (res.ok && res.json && res.json.matched === false) {
49
+ // The agent asked how something works and Convene had no curated answer —
50
+ // the cleanest in-session friction signal. Capture it fire-and-forget
51
+ // (detached + unref'd, exactly as `convene fetch` spawns `convene announce`)
52
+ // so the product learns from confusion automatically instead of relying on a
53
+ // voluntary `convene suggest`. Gated on creds: no key/member → no spawn.
54
+ if (q && cfg.apiKey && cfg.member) {
55
+ try {
56
+ const child = (0, node_child_process_1.spawn)(process.execPath, [process.argv[1], 'friction', '--kind', 'unmatched-explain', '--q', q], { detached: true, stdio: 'ignore' });
57
+ child.unref();
58
+ }
59
+ catch {
60
+ /* fail-open: friction capture must never break `explain` */
61
+ }
62
+ }
48
63
  // Unmatched query — point at the index + bundled essentials (still exit 0).
49
64
  process.stdout.write(`No specific match for "${q}". ${brand_1.BRAND.product} basics:\n\n${bundledSummary(cfg.baseUrl)}\n`);
50
65
  return;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.friction = friction;
4
+ /**
5
+ * `convene friction` — automatic in-session CONFUSION capture. Files ONE
6
+ * low-severity feature_feedback row (category 'friction') when an agent hits a
7
+ * product rough edge the platform should learn from — today: an unmatched
8
+ * `convene explain` query (the agent literally asked how something works and
9
+ * Convene had no curated answer — the cleanest possible friction signal). It is
10
+ * spawned fire-and-forget by the surface that detects the friction (exactly as
11
+ * `convene fetch` spawns `convene announce`), so it NEVER blocks that surface.
12
+ *
13
+ * Mirrors `convene announce`'s posture:
14
+ * - FAIL-OPEN: any error / missing config / non-bus repo exits 0 silently; a 5s
15
+ * watchdog backstops a hang. It can never break the command that spawned it.
16
+ * - IDEMPOTENT: a deterministic server idempotency-key
17
+ * (`friction:<slug>:<instance>:<sigHash>`) is the authoritative dedupe; a local
18
+ * (instance, sigHash) sentinel spares the redundant post when the SAME signal
19
+ * recurs in a session.
20
+ * - PRIVACY: the captured text is the agent's own words about Convene-the-product
21
+ * — the same class of text `convene suggest` already mirrors to maintainers. It
22
+ * is CLIPPED and posted only behind a valid key + project membership (the
23
+ * authenticated-session gate). It never carries prompt text or work content
24
+ * beyond the question the agent typed.
25
+ */
26
+ const node_crypto_1 = require("node:crypto");
27
+ const git_1 = require("../git");
28
+ const config_1 = require("../config");
29
+ const cache_1 = require("../cache");
30
+ const api_1 = require("../api");
31
+ const exit_1 = require("../exit");
32
+ const SIGNAL_MAX = 240;
33
+ function clip(s, max) {
34
+ const t = s.trim();
35
+ return t.length <= max ? t : t.slice(0, max - 1) + '…';
36
+ }
37
+ /** Stable short hash of the normalized signal — keys both the idempotency key and the local sentinel. */
38
+ function signalHash(kind, signal) {
39
+ const norm = `${kind}\n${signal.toLowerCase().replace(/\s+/g, ' ').trim()}`;
40
+ return (0, node_crypto_1.createHash)('sha256').update(norm).digest('hex').slice(0, 16);
41
+ }
42
+ /** Human-readable feedback body for a friction kind. */
43
+ function bodyFor(kind, signal) {
44
+ const q = clip(signal, SIGNAL_MAX);
45
+ switch (kind) {
46
+ case 'unmatched-explain':
47
+ return `Unmatched \`convene explain\` query (no curated answer): "${q}"`;
48
+ default:
49
+ return `[${kind}] ${q}`;
50
+ }
51
+ }
52
+ async function run(opts) {
53
+ const kind = (opts.kind ?? '').trim();
54
+ const signal = (opts.q ?? '').trim();
55
+ if (!kind || !signal)
56
+ return; // no signal → nothing to capture
57
+ const top = (0, git_1.gitToplevel)();
58
+ if (!top)
59
+ return; // not a git repo → silent no-op
60
+ const slug = opts.project || (0, config_1.loadProjectConfig)(top)?.slug || null;
61
+ if (!slug)
62
+ return; // repo not on the bus → silent no-op
63
+ const instance = (0, cache_1.ensureSessionInstance)(slug);
64
+ const sig = signalHash(kind, signal);
65
+ const body = bodyFor(kind, signal);
66
+ if (opts.dryRun) {
67
+ process.stdout.write(body + '\n');
68
+ return;
69
+ }
70
+ // Already captured this exact signal this session — spare the redundant post.
71
+ if ((0, cache_1.frictionAlreadyPosted)(slug, instance, sig))
72
+ return;
73
+ // The authenticated-session gate: only post with a real key + member.
74
+ const cfg = (0, config_1.resolveConfig)();
75
+ if (!cfg.apiKey || !cfg.member)
76
+ return;
77
+ const session = (0, git_1.sessionId)(cfg.member, top);
78
+ const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
79
+ const idem = `friction:${slug}:${instance}:${sig}`;
80
+ const res = await api.post(slug, { type: 'feature_feedback', body, category: 'friction', severity: 'low', tags: [kind] }, idem, 4000);
81
+ if (res.ok) {
82
+ // Only mark on success so a transient failure retries on the next occurrence.
83
+ (0, cache_1.markFrictionPosted)(slug, instance, sig);
84
+ if (res.json?.message?.short_id) {
85
+ process.stdout.write(`convene: captured [FRICTION] ${res.json.message.short_id} (${kind})\n`);
86
+ }
87
+ }
88
+ }
89
+ async function friction(opts = {}) {
90
+ // Backstop: force-exit on every path so a keep-alive socket can't linger.
91
+ const watchdog = setTimeout(() => (0, exit_1.exitClean)(0), 5000);
92
+ watchdog.unref();
93
+ const done = () => {
94
+ clearTimeout(watchdog);
95
+ (0, exit_1.exitClean)(0);
96
+ };
97
+ try {
98
+ await run(opts);
99
+ }
100
+ catch {
101
+ /* fail-open: a friction capture must never break the surface that spawned it */
102
+ }
103
+ done();
104
+ }
@@ -223,72 +223,9 @@ function registerHook(noHook) {
223
223
  log(hookSnippet());
224
224
  }
225
225
  }
226
- /**
227
- * The WP13 coordination hooks, in INSTALL ORDER. Order matters for the two Bash
228
- * PreToolUse entries: `gate-push --stdin` is wired before `guard`, so `guard` lands
229
- * LAST among Bash PreToolUse hooks (awareness/ux #10) — the deploy gate runs, then
230
- * the cheap halt/lane backstop. Each entry names the VERB its binary must support;
231
- * a stale `convene` missing the verb is skipped (so it can't error on every boot).
232
- *
233
- * `convene watch` is NOT a Bash hook — it's a long-running detached daemon that
234
- * `convene session-start` spawns from the SessionStart path (§4.4). Wiring it as a
235
- * blocking Bash/PreToolUse entry would stall; launching from session-start keeps it
236
- * off the discretionary tool path.
237
- */
238
- const COORD_HOOKS = [
239
- {
240
- event: 'SessionStart',
241
- matcher: 'startup|resume|clear',
242
- command: 'convene session-start',
243
- verb: 'session-start',
244
- note: 'auto catch-up digest + mints the session-instance + launches `convene watch`',
245
- },
246
- {
247
- event: 'PreToolUse',
248
- matcher: 'Bash',
249
- command: 'convene gate-push --stdin',
250
- verb: 'gate-push',
251
- note: 'deploy gate before a push (fail-open-loud)',
252
- },
253
- // guard is appended AFTER gate-push so it is LAST among Bash PreToolUse hooks.
254
- {
255
- event: 'PreToolUse',
256
- matcher: 'Bash',
257
- command: 'convene guard',
258
- verb: 'guard',
259
- note: 'halt + lane backstop for Bash (fail-open-loud)',
260
- },
261
- {
262
- event: 'PreToolUse',
263
- matcher: '.*',
264
- command: 'convene guard --halt-only',
265
- verb: 'guard',
266
- note: 'cheap directed-halt backstop on every tool call',
267
- },
268
- {
269
- event: 'PostToolUse',
270
- matcher: 'Bash',
271
- command: 'convene gate-push --post',
272
- verb: 'gate-push',
273
- note: 'release the deploy lane after a push (idempotent)',
274
- },
275
- {
276
- event: 'PostToolUse',
277
- matcher: 'Edit|Write|MultiEdit',
278
- command: 'convene beat --stdin',
279
- verb: 'beat',
280
- note: 'debounced session activity-beat so a heads-down session still pulses on the bus',
281
- },
282
- // Stop fires at every turn-end (no tool matcher); `convene wrap` is idempotent +
283
- // debounced via the last-broadcast-sha cursor, so it posts at most one wrap per
284
- // stretch of new committed work and stays silent on idle turns. Never blocks.
285
- {
286
- event: 'Stop',
287
- command: 'convene wrap',
288
- verb: 'wrap',
289
- note: 'session-end wrap status when new committed work landed (idempotent, fail-open)',
290
- },
291
- ];
226
+ // COORD_HOOKS (the canonical coordination-hook set, in install order) + the
227
+ // missingCoordHooks drift-check now live in ../hook as the SINGLE source of truth,
228
+ // so the writer here and the `convene doctor` drift-check can never disagree.
292
229
  /**
293
230
  * Wire the WP13 coordination hooks into a settings file (global or committed
294
231
  * project), idempotent + merge-safe via ensureHook (deep-clone, never clobber,
@@ -297,7 +234,7 @@ const COORD_HOOKS = [
297
234
  */
298
235
  function registerCoordinationHooks(settingsPath, label) {
299
236
  let unparseable = false;
300
- for (const h of COORD_HOOKS) {
237
+ for (const h of hook_1.COORD_HOOKS) {
301
238
  if (!(0, hook_1.binarySupportsVerb)(h.verb)) {
302
239
  log(`· ${label}: skipped \`${h.command}\` — installed \`convene\` lacks \`${h.verb}\` (upgrade the CLI to enable).`);
303
240
  continue;
@@ -15,6 +15,7 @@ const config_1 = require("../config");
15
15
  const git_1 = require("../git");
16
16
  const hook_1 = require("../hook");
17
17
  const githook_1 = require("../githook");
18
+ const enroll_1 = require("./enroll");
18
19
  const ctx_1 = require("../ctx");
19
20
  const log = (m) => process.stdout.write(m + '\n');
20
21
  async function join(opts) {
@@ -34,8 +35,25 @@ async function join(opts) {
34
35
  // unauthenticated mode otherwise (creates a new identity + key).
35
36
  const api = new api_1.ConveneApi(baseUrl, cfg.apiKey ?? null);
36
37
  const res = await api.join(slug, { token, handle, email, display_name: handle, tool: 'cli' }, 10_000);
37
- if (res.status === 409)
38
- (0, ctx_1.die)(`the handle "${handle}" is already taken — re-run with --handle <unique-handle>`);
38
+ if (res.status === 409) {
39
+ const code = res.json?.code;
40
+ const keyHint = `paste your key (Settings → API key at ${brand_1.BRAND.siteUrl}/settings, or ~/.convene/config.json on your other machine)`;
41
+ if (code === 'EMAIL_TAKEN') {
42
+ // Same human, new machine: connect THIS machine to the existing identity with NO key
43
+ // copying — prove email ownership via an emailed link, then re-run join authenticated
44
+ // so the (now-known) member is added to this project (idempotent if already a member).
45
+ log(`An identity already exists for ${email ?? 'your email'} — connecting this machine to it (no key copying needed).`);
46
+ const ok = await (0, enroll_1.enrollDevice)({ email, baseUrl });
47
+ if (ok)
48
+ return join(opts);
49
+ return; // enrollDevice prints guidance + exits on failure
50
+ }
51
+ (0, ctx_1.die)(`The handle "${handle}" is already taken on this bus.\n` +
52
+ `If that's you (same person, another machine), connect to your existing identity:\n` +
53
+ ` convene login --api-key - # ${keyHint}\n` +
54
+ `If you want a SEPARATE identity here, pick a different handle via your email:\n` +
55
+ ` convene setup --email <a-different-email> # the handle is derived from the email's local part`);
56
+ }
39
57
  if (res.status === 401 && cfg.apiKey)
40
58
  (0, ctx_1.die)('join token is invalid/expired/revoked, or your saved key is bad — ask an owner');
41
59
  if (res.status === 401)