@clubnet/seedclub 0.2.31 → 0.2.33

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.
@@ -80,8 +80,10 @@ async function apiRequest<T>(endpoint: string, options: RequestOptions = {}): Pr
80
80
 
81
81
  const response = await fetch(url.toString(), {
82
82
  method,
83
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
84
- body: body ? JSON.stringify(body) : undefined,
83
+ headers: body === undefined
84
+ ? { Authorization: `Bearer ${token}` }
85
+ : { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
86
+ body: body === undefined ? undefined : JSON.stringify(body),
85
87
  });
86
88
 
87
89
  const text = await response.text();
@@ -20,8 +20,6 @@ const DEFAULT_AUTH_BASE = "https://auth.seedclub.com";
20
20
  const LOCAL_API_BASE = "http://localhost:3001";
21
21
  const LOCAL_AUTH_BASE = "http://localhost:3000";
22
22
 
23
- export type SeedclubEnvironmentMode = "local" | "prod" | "custom";
24
-
25
23
  export interface StoredToken {
26
24
  token: string;
27
25
  email: string;
@@ -34,8 +32,6 @@ export interface StoredToken {
34
32
  export interface StoredBases {
35
33
  apiBase: string;
36
34
  authBase: string;
37
- mode: SeedclubEnvironmentMode;
38
- updatedAt: string;
39
35
  }
40
36
 
41
37
  let _cachedApiBase: string | null = null;
@@ -50,6 +46,23 @@ function shouldPreferLocalBases(): boolean {
50
46
  );
51
47
  }
52
48
 
49
+ function normalizeStoredBases(value: unknown): StoredBases | null {
50
+ if (!value || typeof value !== "object") {
51
+ return null;
52
+ }
53
+
54
+ const stored = value as Partial<StoredBases>;
55
+
56
+ if (typeof stored.apiBase !== "string" || typeof stored.authBase !== "string") {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ apiBase: stored.apiBase,
62
+ authBase: stored.authBase,
63
+ };
64
+ }
65
+
53
66
  function tryReadStoredBasesSync(): StoredBases | null {
54
67
  if (_cachedBases !== undefined) {
55
68
  return _cachedBases;
@@ -57,13 +70,8 @@ function tryReadStoredBasesSync(): StoredBases | null {
57
70
 
58
71
  try {
59
72
  const content = readFileSync(BASES_FILE, "utf-8");
60
- const stored = JSON.parse(content) as StoredBases;
61
- if (
62
- !stored ||
63
- typeof stored.apiBase !== "string" ||
64
- typeof stored.authBase !== "string" ||
65
- typeof stored.mode !== "string"
66
- ) {
73
+ const stored = normalizeStoredBases(JSON.parse(content));
74
+ if (!stored) {
67
75
  _cachedBases = null;
68
76
  return null;
69
77
  }
@@ -158,13 +166,8 @@ export async function getStoredToken(): Promise<StoredToken | null> {
158
166
  export async function getStoredBases(): Promise<StoredBases | null> {
159
167
  try {
160
168
  const content = await readFile(BASES_FILE, "utf-8");
161
- const stored = JSON.parse(content) as StoredBases;
162
- if (
163
- !stored ||
164
- typeof stored.apiBase !== "string" ||
165
- typeof stored.authBase !== "string" ||
166
- typeof stored.mode !== "string"
167
- ) {
169
+ const stored = normalizeStoredBases(JSON.parse(content));
170
+ if (!stored) {
168
171
  return null;
169
172
  }
170
173
  _cachedBases = stored;
@@ -183,13 +186,10 @@ export async function getToken(): Promise<string | null> {
183
186
  export async function storeBases(
184
187
  apiBase: string,
185
188
  authBase: string,
186
- mode: SeedclubEnvironmentMode = "custom",
187
189
  ): Promise<void> {
188
190
  const stored: StoredBases = {
189
191
  apiBase,
190
192
  authBase,
191
- mode,
192
- updatedAt: new Date().toISOString(),
193
193
  };
194
194
  _cachedApiBase = apiBase;
195
195
  _cachedAuthBase = authBase;
@@ -41,7 +41,6 @@ async function showSeedEnvironment(ctx: any) {
41
41
  const lines = [
42
42
  `effective api: ${getApiBase()}`,
43
43
  `effective auth: ${getAuthBase()}`,
44
- `stored mode: ${storedBases?.mode ?? "none"}`,
45
44
  `stored api: ${storedBases?.apiBase ?? "none"}`,
46
45
  `stored auth: ${storedBases?.authBase ?? "none"}`,
47
46
  ];
@@ -50,7 +49,7 @@ async function showSeedEnvironment(ctx: any) {
50
49
 
51
50
  async function setSeedEnvironment(mode: "local" | "prod", ctx: any) {
52
51
  const bases = getDefaultBases(mode);
53
- await storeBases(bases.apiBase, bases.authBase, mode);
52
+ await storeBases(bases.apiBase, bases.authBase);
54
53
  ctx.ui.notify(
55
54
  `Seed Club environment set to ${mode}.\napi: ${bases.apiBase}\nauth: ${bases.authBase}\nReconnect if your current token belongs to a different environment.`,
56
55
  "info",
@@ -113,13 +112,15 @@ export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
113
112
  const isConnected = !!stored || hasEnvToken;
114
113
 
115
114
  if (!isConnected) {
116
- return await deps.connect(args, ctx);
115
+ await deps.connect(args, ctx);
116
+ return;
117
117
  }
118
118
 
119
119
  const user = await getCurrentUser();
120
120
  if ("error" in user) {
121
121
  ctx.ui.notify("Session expired. Reconnecting...", "info");
122
- return await deps.connect(args, ctx);
122
+ await deps.connect(args, ctx);
123
+ return;
123
124
  }
124
125
 
125
126
  const session = await getSessionContext();
@@ -16,9 +16,9 @@ export const AUTH_GATE_ALLOWED_COMMANDS = new Set([
16
16
  ]);
17
17
 
18
18
  const state: SeedclubAuthGateState = {
19
- status: "auth_required",
19
+ status: "auth_in_progress",
20
20
  authUrl: null,
21
- message: "Seed Club sign-in is required before /login or /model.",
21
+ message: "Checking Seed Club access...",
22
22
  error: null,
23
23
  };
24
24
 
@@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto";
9
9
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
10
10
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
11
  import { api, clearCredentials, setCachedToken } from "./api-client.js";
12
- import { getApiBase, getAuthBase, getStoredBases, getStoredToken, storeToken } from "./auth.js";
12
+ import { getApiBase, getAuthBase, getStoredToken, storeToken } from "./auth.js";
13
13
  import { renderCallbackPage } from "./browser-pages.js";
14
14
  import { registerSeedclubCommand } from "./commands/seedclub.js";
15
15
  import { registerTranscriptsCommand } from "./commands/transcripts.js";
@@ -93,7 +93,6 @@ export default function (pi: ExtensionAPI) {
93
93
  }
94
94
 
95
95
  async function applyConnectedStatus(ctx: any, user: { name?: string | null; email?: string | null }) {
96
- const storedBases = await getStoredBases();
97
96
  const effectiveApiBase = getApiBase();
98
97
  const effectiveAuthBase = getAuthBase();
99
98
  const isDev = effectiveApiBase.includes("localhost") || effectiveApiBase.includes("127.0.0.1");
@@ -102,8 +101,7 @@ export default function (pi: ExtensionAPI) {
102
101
  (effectiveAuthBase.includes("localhost") || effectiveAuthBase.includes("127.0.0.1"));
103
102
 
104
103
  ctx.ui.setStatus("seed", formatSeedLabel(user.name ?? undefined, user.email ?? undefined));
105
- if (storedBases?.mode) ctx.ui.setStatus("seed-env", `env: ${storedBases.mode}`);
106
- else ctx.ui.setStatus("seed-env", undefined);
104
+ ctx.ui.setStatus("seed-env", undefined);
107
105
  if (isDev) ctx.ui.setStatus("seed-api", `dev: ${effectiveApiBase}`);
108
106
  else ctx.ui.setStatus("seed-api", undefined);
109
107
  if (hasSeparateDevAuthBase) ctx.ui.setStatus("seed-auth", `auth: ${effectiveAuthBase}`);
@@ -139,15 +137,13 @@ export default function (pi: ExtensionAPI) {
139
137
  }
140
138
 
141
139
  await applyConnectedStatus(ctx, user);
140
+ markAuthComplete(getPostAuthInstruction(ctx));
142
141
  return user;
143
142
  }
144
143
 
145
144
  async function ensureSeedclubAuthenticated(ctx: any): Promise<boolean> {
146
145
  const existing = await validateCurrentCredential(ctx);
147
- if (existing) {
148
- markAuthComplete(getPostAuthInstruction(ctx));
149
- return true;
150
- }
146
+ if (existing) return true;
151
147
  return connect(undefined, ctx, { autoStart: true });
152
148
  }
153
149
 
@@ -111,9 +111,9 @@ export function makeProgressCallRenderer(
111
111
  label: string,
112
112
  detail?: (args: any) => string | undefined,
113
113
  ) {
114
- return (args: any, theme: any, context: any) => {
115
- const state = (context.state ??= {});
116
- if (context.isPartial) {
114
+ return (args: any, theme: any, context?: any) => {
115
+ const state = context?.state;
116
+ if (context?.isPartial && state) {
117
117
  if (!state.spinnerTimer) {
118
118
  state.spinnerFrame = 0;
119
119
  state.spinnerTimer = setInterval(() => {
@@ -122,13 +122,13 @@ export function makeProgressCallRenderer(
122
122
  }, 90);
123
123
  state.spinnerTimer.unref?.();
124
124
  }
125
- } else if (state.spinnerTimer) {
125
+ } else if (state?.spinnerTimer) {
126
126
  clearInterval(state.spinnerTimer);
127
127
  state.spinnerTimer = undefined;
128
128
  }
129
129
 
130
- const frame = context.isPartial ? SPINNER_FRAMES[state.spinnerFrame ?? 0] : "";
131
- const tone = context.isPartial ? "accent" : context.isError ? "error" : "success";
130
+ const frame = context?.isPartial && state ? SPINNER_FRAMES[state.spinnerFrame ?? 0] : "";
131
+ const tone = context?.isPartial ? "accent" : context?.isError ? "error" : "muted";
132
132
  let text = `${theme.fg(tone, frame)} ${theme.fg("toolTitle", label)}`;
133
133
  const detailText = detail?.(args);
134
134
  if (detailText) text += theme.fg("dim", ` · ${detailText}`);
@@ -158,9 +158,9 @@ export function makeProgressResultRenderer(
158
158
  successLabel = "Completed",
159
159
  summary?: (details: any, args: any) => string | undefined,
160
160
  ) {
161
- return (result: any, options: any, theme: any, context: any) => {
162
- const state = context.state ?? {};
163
- if (state.spinnerTimer) {
161
+ return (result: any, options: any, theme: any, context?: any) => {
162
+ const state = context?.state;
163
+ if (state?.spinnerTimer) {
164
164
  clearInterval(state.spinnerTimer);
165
165
  state.spinnerTimer = undefined;
166
166
  }
@@ -170,14 +170,18 @@ export function makeProgressResultRenderer(
170
170
  if (result?.isError) {
171
171
  const line = firstTextLine(result?.content) ?? "Request failed";
172
172
  let text = `${theme.fg("error", "✕")} ${theme.fg("error", line)}`;
173
- const debugText = getCachedDebugText(state, result, options?.expanded === true);
173
+ const debugText = state
174
+ ? getCachedDebugText(state, result, options?.expanded === true)
175
+ : getDebugText(result, options?.expanded === true);
174
176
  if (debugText) text += `\n${theme.fg("dim", debugText)}`;
175
177
  return new Text(text, 0, 0);
176
178
  }
177
179
  const detailText = summary?.(result?.details, context?.args) ?? summarizeDetails(result?.details);
178
180
  let text = `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}`;
179
181
  if (detailText) text += theme.fg("dim", ` · ${detailText}`);
180
- const debugText = getCachedDebugText(state, result, options?.expanded === true);
182
+ const debugText = state
183
+ ? getCachedDebugText(state, result, options?.expanded === true)
184
+ : getDebugText(result, options?.expanded === true);
181
185
  if (debugText) text += `\n${theme.fg("dim", debugText)}`;
182
186
  return new Text(text, 0, 0);
183
187
  };
@@ -195,13 +195,22 @@ export function registerCrmTools(pi: ExtensionAPI) {
195
195
  renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_program_contacts"), (args) => args?.programSlug || undefined),
196
196
  renderResult(result: any, _args: any, theme: any) {
197
197
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
198
+ const successLabel = getToolSuccessLabel("seedclub_list_program_contacts");
198
199
  const details = result.details ?? {};
199
200
  const rows = Array.isArray(details?.data) ? details.data : [];
200
201
  const program = details?.program?.slug ?? details?.program?.name ?? "program";
201
- if (!rows.length) return new Text(theme.fg("dim", `No contacts found for ${program}`), 0, 0);
202
+ if (!rows.length) {
203
+ return new Text(
204
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}${theme.fg("dim", ` · No contacts found for ${program}`)}`,
205
+ 0,
206
+ 0,
207
+ );
208
+ }
202
209
 
203
210
  const shown = rows.slice(0, 8);
204
- let text = theme.fg("muted", `${rows.length} contact${rows.length === 1 ? "" : "s"} loaded for ${program}`);
211
+ let text =
212
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` +
213
+ theme.fg("dim", ` · ${rows.length} contact${rows.length === 1 ? "" : "s"} loaded for ${program}`);
205
214
  for (const row of shown) {
206
215
  const name = row?.party?.display_name ?? row?.person?.full_name ?? "Unknown";
207
216
  const org = row?.roles?.[0]?.organization_name ?? row?.organization?.name ?? null;
@@ -222,11 +222,20 @@ export function registerMediaTools(pi: ExtensionAPI) {
222
222
  renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_program_media_assets"), (args) => args?.programSlug || undefined),
223
223
  renderResult(result: any, { expanded }: any, theme: any) {
224
224
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
225
+ const successLabel = getToolSuccessLabel("seedclub_list_program_media_assets");
225
226
  const rows = Array.isArray(result.details?.data) ? result.details.data : [];
226
227
  const program = result.details?.program?.slug ?? result.details?.program?.name ?? "program";
227
- if (!rows.length) return new Text(theme.fg("dim", `No media assets for ${program}`), 0, 0);
228
+ if (!rows.length) {
229
+ return new Text(
230
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}${theme.fg("dim", ` · No media assets for ${program}`)}`,
231
+ 0,
232
+ 0,
233
+ );
234
+ }
228
235
 
229
- let text = theme.fg("muted", `${rows.length} media asset${rows.length === 1 ? "" : "s"} for ${program}`);
236
+ let text =
237
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` +
238
+ theme.fg("dim", ` · ${rows.length} media asset${rows.length === 1 ? "" : "s"} for ${program}`);
230
239
  for (const row of rows.slice(0, expanded ? 12 : 6)) {
231
240
  const asset = row?.asset ?? row;
232
241
  const date = asset?.event_date ?? "unknown date";
@@ -259,14 +268,26 @@ export function registerMediaTools(pi: ExtensionAPI) {
259
268
  renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_get_program_media_asset"), (args) => args?.assetId || undefined),
260
269
  renderResult(result: any, _args: any, theme: any) {
261
270
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
271
+ const successLabel = getToolSuccessLabel("seedclub_get_program_media_asset");
262
272
  const asset = result.details?.asset ?? null;
263
- if (!asset) return new Text(theme.fg("dim", "No media asset found"), 0, 0);
273
+ if (!asset) {
274
+ return new Text(
275
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}${theme.fg("dim", " · No media asset found")}`,
276
+ 0,
277
+ 0,
278
+ );
279
+ }
264
280
  const date = asset?.event_date ?? "unknown date";
265
281
  const title = asset?.title ?? asset?.file_name ?? "untitled";
266
282
  const kind = asset?.asset_kind ?? "asset";
267
283
  const hasText = asset?.has_transcript_text ? "text" : "no-text";
268
284
  const hasVtt = asset?.has_transcript_vtt ? "vtt" : "no-vtt";
269
- return new Text(theme.fg("muted", `${date} - ${title} [${kind}; ${hasText}; ${hasVtt}]`), 0, 0);
285
+ return new Text(
286
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` +
287
+ theme.fg("dim", ` · ${date} - ${title} [${kind}; ${hasText}; ${hasVtt}]`),
288
+ 0,
289
+ 0,
290
+ );
270
291
  },
271
292
  });
272
293
 
@@ -39,6 +39,26 @@ const GUEST_ROSTER_DEFAULT_FIELDS = [
39
39
  "organizationRole",
40
40
  ];
41
41
 
42
+ function summarizeCount(count: number | undefined, noun: string) {
43
+ return typeof count === "number" ? `${count} ${noun}${count === 1 ? "" : "s"}` : undefined;
44
+ }
45
+
46
+ function countFromArrays(...values: any[]) {
47
+ for (const value of values) {
48
+ if (Array.isArray(value)) return value.length;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ function firstNonEmptyString(...values: unknown[]) {
54
+ for (const value of values) {
55
+ if (typeof value === "string" && value.trim()) {
56
+ return value.trim();
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
42
62
  function normalizeLimit(limit: number | undefined, defaults: { fallback: number; max: number }) {
43
63
  if (!Number.isFinite(limit)) return defaults.fallback;
44
64
  return Math.max(1, Math.min(defaults.max, Math.trunc(limit!)));
@@ -153,6 +173,49 @@ function formatSlotRange(startMinutes: number, endMinutes: number, timeZone: str
153
173
  return `${formatter.format(start)} - ${formatter.format(end)}`;
154
174
  }
155
175
 
176
+ function formatMeetingDateTimeRange(startsAt: string | null | undefined, endsAt: string | null | undefined) {
177
+ if (typeof startsAt !== "string" || !startsAt.trim()) return undefined;
178
+ const start = new Date(startsAt);
179
+ if (Number.isNaN(start.getTime())) return undefined;
180
+
181
+ const end =
182
+ typeof endsAt === "string" && endsAt.trim()
183
+ ? new Date(endsAt)
184
+ : null;
185
+
186
+ const dateFormatter = new Intl.DateTimeFormat("en-US", {
187
+ weekday: "short",
188
+ month: "short",
189
+ day: "numeric",
190
+ });
191
+ const timeFormatter = new Intl.DateTimeFormat("en-US", {
192
+ hour: "numeric",
193
+ minute: "2-digit",
194
+ hour12: true,
195
+ });
196
+
197
+ const startDateLabel = dateFormatter.format(start);
198
+ const startTimeLabel = timeFormatter.format(start);
199
+
200
+ if (!end || Number.isNaN(end.getTime())) {
201
+ return `${startDateLabel} · ${startTimeLabel}`;
202
+ }
203
+
204
+ const sameDay =
205
+ start.getFullYear() === end.getFullYear() &&
206
+ start.getMonth() === end.getMonth() &&
207
+ start.getDate() === end.getDate();
208
+
209
+ const endDateLabel = dateFormatter.format(end);
210
+ const endTimeLabel = timeFormatter.format(end);
211
+
212
+ if (sameDay) {
213
+ return `${startDateLabel} · ${startTimeLabel}-${endTimeLabel}`;
214
+ }
215
+
216
+ return `${startDateLabel} ${startTimeLabel} - ${endDateLabel} ${endTimeLabel}`;
217
+ }
218
+
156
219
  function shapeTranscriptSource(source: any, includeFullText: boolean, includePreview = true) {
157
220
  if (!source) {
158
221
  return null;
@@ -1423,7 +1486,7 @@ async function findCommonMeetingAvailability(args: {
1423
1486
  }
1424
1487
  }
1425
1488
 
1426
- async function bookMeetingFull(args: {
1489
+ async function bookMeeting(args: {
1427
1490
  startsAt: string;
1428
1491
  endsAt?: string | null;
1429
1492
  durationMinutes?: number | null;
@@ -1440,7 +1503,7 @@ async function bookMeetingFull(args: {
1440
1503
  }) {
1441
1504
  try {
1442
1505
  const resolvedEndsAt = resolveBookingEndsAt(args.startsAt, args.endsAt, args.durationMinutes);
1443
- return await api.post<any>("/meetings/book-full", {
1506
+ return await api.post<any>("/meetings/book", {
1444
1507
  calendar_account_id: args.calendarAccountId,
1445
1508
  program_slug: args.programSlug,
1446
1509
  starts_at: args.startsAt,
@@ -1463,6 +1526,44 @@ async function bookMeetingFull(args: {
1463
1526
  }
1464
1527
  }
1465
1528
 
1529
+ async function rescheduleMeeting(args: {
1530
+ meetingId: string;
1531
+ startsAt: string;
1532
+ endsAt?: string | null;
1533
+ durationMinutes?: number | null;
1534
+ timeZone?: string;
1535
+ title?: string | null;
1536
+ description?: string | null;
1537
+ calendarAccountId?: string | null;
1538
+ }) {
1539
+ try {
1540
+ const resolvedEndsAt = resolveBookingEndsAt(args.startsAt, args.endsAt, args.durationMinutes);
1541
+ return await api.post<any>(`/meetings/${args.meetingId}/reschedule`, {
1542
+ calendar_account_id: args.calendarAccountId,
1543
+ starts_at: args.startsAt,
1544
+ ends_at: resolvedEndsAt,
1545
+ duration_minutes: args.durationMinutes,
1546
+ time_zone: args.timeZone,
1547
+ title: args.title,
1548
+ description: args.description,
1549
+ });
1550
+ } catch (error) {
1551
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
1552
+ throw error;
1553
+ }
1554
+ }
1555
+
1556
+ async function cancelMeeting(args: { meetingId: string; calendarAccountId?: string | null }) {
1557
+ try {
1558
+ return await api.post<any>(`/meetings/${args.meetingId}/cancel`, {
1559
+ calendar_account_id: args.calendarAccountId,
1560
+ });
1561
+ } catch (error) {
1562
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
1563
+ throw error;
1564
+ }
1565
+ }
1566
+
1466
1567
  async function updateMeeting(args: {
1467
1568
  meetingId: string;
1468
1569
  startsAt?: string | null;
@@ -1512,6 +1613,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1512
1613
  ),
1513
1614
  }),
1514
1615
  execute: wrapExecute(searchPeopleForBooking),
1616
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_search_people"), (args) => args?.query || undefined),
1617
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_search_people"), (details) =>
1618
+ summarizeCount(countFromArrays(details?.data), "match"),
1619
+ ),
1515
1620
  });
1516
1621
 
1517
1622
  pi.registerTool({
@@ -1527,6 +1632,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1527
1632
  timeZone: Type.Optional(Type.String({ description: "Optional timezone used to interpret the requested date. Defaults to America/New_York." })),
1528
1633
  }),
1529
1634
  execute: wrapExecute(listMeetingAvailabilitySlots),
1635
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_meeting_availability"), (args) => args?.date || undefined),
1636
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_meeting_availability"), (details) =>
1637
+ summarizeCount(countFromArrays(details?.slots), "slot"),
1638
+ ),
1530
1639
  });
1531
1640
 
1532
1641
  pi.registerTool({
@@ -1538,6 +1647,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1538
1647
  programSlug: Type.Optional(Type.String({ description: "Program slug. Defaults to 11am on the server." })),
1539
1648
  }),
1540
1649
  execute: wrapExecute(listMeetingCalendars),
1650
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_meeting_calendars"), (args) => args?.programSlug || undefined),
1651
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_meeting_calendars"), (details) =>
1652
+ summarizeCount(countFromArrays(details?.data, details?.calendars), "calendar"),
1653
+ ),
1541
1654
  });
1542
1655
 
1543
1656
  pi.registerTool({
@@ -1554,6 +1667,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1554
1667
  query: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Optional Google Calendar search query." })),
1555
1668
  }),
1556
1669
  execute: wrapExecute(listPersonalCalendarEvents),
1670
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_personal_calendar_events"), (args) => args?.accountId || args?.query || undefined),
1671
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_personal_calendar_events"), (details) =>
1672
+ summarizeCount(countFromArrays(details?.data, details?.events, details?.items), "event"),
1673
+ ),
1557
1674
  });
1558
1675
 
1559
1676
  pi.registerTool({
@@ -1571,6 +1688,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1571
1688
  attendeeEmails: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
1572
1689
  }),
1573
1690
  execute: wrapExecute(createPersonalCalendarEvent),
1691
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_create_personal_calendar_event"), (args) => args?.summary || undefined),
1692
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_create_personal_calendar_event"), (details) =>
1693
+ firstNonEmptyString(details?.event?.id, details?.id, details?.event_id, details?.eventId, details?.summary),
1694
+ ),
1574
1695
  });
1575
1696
 
1576
1697
  pi.registerTool({
@@ -1589,6 +1710,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1589
1710
  attendeeEmails: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
1590
1711
  }),
1591
1712
  execute: wrapExecute(updatePersonalCalendarEvent),
1713
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_update_personal_calendar_event"), (args) => args?.eventId || undefined),
1714
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_update_personal_calendar_event"), (details, args) =>
1715
+ firstNonEmptyString(details?.event?.id, details?.id, details?.event_id, details?.eventId, args?.eventId),
1716
+ ),
1592
1717
  });
1593
1718
 
1594
1719
  pi.registerTool({
@@ -1601,6 +1726,10 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1601
1726
  eventId: Type.String({ description: "Google Calendar event id." }),
1602
1727
  }),
1603
1728
  execute: wrapExecute(deletePersonalCalendarEvent),
1729
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_delete_personal_calendar_event"), (args) => args?.eventId || undefined),
1730
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_delete_personal_calendar_event"), (_details, args) =>
1731
+ firstNonEmptyString(args?.eventId, "deleted"),
1732
+ ),
1604
1733
  });
1605
1734
 
1606
1735
  pi.registerTool({
@@ -1628,6 +1757,12 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1628
1757
  ),
1629
1758
  }),
1630
1759
  execute: wrapExecute(findCommonMeetingAvailability),
1760
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_find_common_meeting_availability"), (args) =>
1761
+ summarizeCount(Array.isArray(args?.calendarAccountIds) ? args.calendarAccountIds.length : undefined, "calendar"),
1762
+ ),
1763
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_find_common_meeting_availability"), (details) =>
1764
+ summarizeCount(countFromArrays(details?.slots, details?.data, details?.availability, details?.matches), "slot"),
1765
+ ),
1631
1766
  });
1632
1767
 
1633
1768
  pi.registerTool({
@@ -1755,13 +1890,23 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1755
1890
  renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_find_latest_guest_transcript"), (args) => args?.guest || undefined),
1756
1891
  renderResult(result: any, _args: any, theme: any) {
1757
1892
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
1893
+ const successLabel = getToolSuccessLabel("seedclub_find_latest_guest_transcript");
1758
1894
  const found = result.details?.found === true;
1759
1895
  if (!found) {
1760
- return new Text(theme.fg("dim", result.details?.reason || "No matching transcript found"), 0, 0);
1896
+ return new Text(
1897
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` +
1898
+ theme.fg("dim", ` · ${result.details?.reason || "No matching transcript found"}`),
1899
+ 0,
1900
+ 0,
1901
+ );
1761
1902
  }
1762
1903
  const row = result.details?.result ?? {};
1763
1904
  const status = `${row?.eventDate ?? "unknown date"} · ${row?.guest ?? "guest"} · text:${row?.hasText ? "yes" : "no"} · vtt:${row?.hasVtt ? "yes" : "no"}`;
1764
- return new Text(theme.fg("muted", status), 0, 0);
1905
+ return new Text(
1906
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` + theme.fg("dim", ` · ${status}`),
1907
+ 0,
1908
+ 0,
1909
+ );
1765
1910
  },
1766
1911
  });
1767
1912
 
@@ -1783,6 +1928,7 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1783
1928
  renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_prepare_clip_packet"), (args) => args?.meetingId || undefined),
1784
1929
  renderResult(result: any, _args: any, theme: any) {
1785
1930
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
1931
+ const successLabel = getToolSuccessLabel("seedclub_prepare_clip_packet");
1786
1932
 
1787
1933
  const details = result.details ?? {};
1788
1934
  const meeting = details.meeting ?? {};
@@ -1799,10 +1945,12 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1799
1945
  ? details.clipReadiness.reasons.filter((reason: any) => typeof reason === "string")
1800
1946
  : [];
1801
1947
 
1802
- let text = theme.fg(
1803
- "muted",
1804
- `${eventDate} · ${guestName} · transcript:${hasTranscript ? "yes" : "no"} · recording:${hasRecording ? "yes" : "no"} · ready:${ready ? "yes" : "no"}`,
1805
- );
1948
+ let text =
1949
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` +
1950
+ theme.fg(
1951
+ "dim",
1952
+ ` · ${eventDate} · ${guestName} · transcript:${hasTranscript ? "yes" : "no"} · recording:${hasRecording ? "yes" : "no"} · ready:${ready ? "yes" : "no"}`,
1953
+ );
1806
1954
  if (!ready && reasons.length) {
1807
1955
  text += theme.fg("dim", `\n blockers: ${reasons.join(", ")}`);
1808
1956
  }
@@ -1864,15 +2012,24 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1864
2012
  renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_meeting_transcripts"), (args) => args?.programSlug || undefined),
1865
2013
  renderResult(result: any, renderArgs: any, theme: any) {
1866
2014
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
2015
+ const successLabel = getToolSuccessLabel("seedclub_list_meeting_transcripts");
1867
2016
  const expanded = renderArgs?.expanded === true;
1868
2017
  const requestedLimit = Number.isFinite(renderArgs?.limit) ? Math.trunc(renderArgs.limit) : DEFAULT_TRANSCRIPT_LIMIT;
1869
2018
  const rows = Array.isArray(result.details?.data) ? result.details.data : [];
1870
2019
  const program = result.details?.program?.slug ?? result.details?.program?.name ?? "program";
1871
- if (!rows.length) return new Text(theme.fg("dim", `No transcripts found for ${program}`), 0, 0);
2020
+ if (!rows.length) {
2021
+ return new Text(
2022
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}${theme.fg("dim", ` · No transcripts found for ${program}`)}`,
2023
+ 0,
2024
+ 0,
2025
+ );
2026
+ }
1872
2027
 
1873
2028
  const visibleCount = expanded ? 20 : 8;
1874
2029
  const shown = Math.min(visibleCount, rows.length);
1875
- let text = theme.fg("muted", `${rows.length} transcript${rows.length === 1 ? "" : "s"} loaded for ${program}`);
2030
+ let text =
2031
+ `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}` +
2032
+ theme.fg("dim", ` · ${rows.length} transcript${rows.length === 1 ? "" : "s"} loaded for ${program}`);
1876
2033
  for (const row of rows.slice(0, shown)) {
1877
2034
  const date = row?.transcript?.event_date ?? "unknown date";
1878
2035
  const name = row?.transcript_for ?? "Unknown";
@@ -1928,7 +2085,7 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1928
2085
  });
1929
2086
 
1930
2087
  pi.registerTool({
1931
- name: "seedclub_book_meeting_full",
2088
+ name: "seedclub_book_meeting",
1932
2089
  label: "Book Meeting",
1933
2090
  description:
1934
2091
  "Book one guest meeting end-to-end: create the meeting, create the studio link, and create the Google Calendar invite. Always fetch availability first with seedclub_list_meeting_availability and use one of the returned startsAt/endsAt pairs. For common meetings, use seedclub_list_meeting_calendars and seedclub_find_common_meeting_availability first, then pass the chosen calendarAccountId. Only use this after you know guestName, guestEmail, and startsAt. Prefer passing endsAt when using an availability slot. If they only give a slot or duration, pass durationMinutes instead; if neither is available, this tool defaults to a 20-minute meeting, but the backend will still reject times that do not exactly match a configured slot. Search for an existing guest first and pass partyId when you have a confident match; otherwise proceed without partyId and the backend will find or create a contact using guestEmail before booking.",
@@ -1959,7 +2116,11 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1959
2116
  profileImageUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])),
1960
2117
  programSlug: Type.Optional(Type.String({ description: "Program slug. Defaults to 11am on the server." })),
1961
2118
  }),
1962
- execute: wrapExecute(bookMeetingFull),
2119
+ execute: wrapExecute(bookMeeting),
2120
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_book_meeting_full"), (args) => args?.guestName || undefined),
2121
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_book_meeting_full"), (details, args) =>
2122
+ firstNonEmptyString(details?.meeting?.id, details?.meeting_id, details?.meetingId, details?.meeting?.title, args?.guestName),
2123
+ ),
1963
2124
  });
1964
2125
 
1965
2126
  pi.registerTool({
@@ -1974,7 +2135,13 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1974
2135
  producerFraming: Type.Optional(Type.Union([Type.String(), Type.Null()])),
1975
2136
  }),
1976
2137
  execute: wrapExecute(updateMeeting),
1977
- renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_update_meeting"), (args) => args?.meetingId || undefined),
2138
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_update_meeting"), (args) =>
2139
+ firstNonEmptyString(
2140
+ formatMeetingDateTimeRange(args?.startsAt, args?.endsAt),
2141
+ args?.title,
2142
+ args?.meetingId,
2143
+ ),
2144
+ ),
1978
2145
  renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_update_meeting")),
1979
2146
  });
1980
2147
 
@@ -8,6 +8,14 @@ export const TOOL_CALL_LABELS: Record<string, string> = {
8
8
  seedclub_create_crm_note: "Saving CRM note",
9
9
  seedclub_create_crm_task: "Creating CRM task",
10
10
  seedclub_list_program_contacts: "Loading program contacts",
11
+ seedclub_search_people: "Searching people",
12
+ seedclub_list_meeting_availability: "Loading availability slots",
13
+ seedclub_list_meeting_calendars: "Loading booking calendars",
14
+ seedclub_list_personal_calendar_events: "Loading personal calendar events",
15
+ seedclub_create_personal_calendar_event: "Creating personal calendar event",
16
+ seedclub_update_personal_calendar_event: "Updating personal calendar event",
17
+ seedclub_delete_personal_calendar_event: "Deleting personal calendar event",
18
+ seedclub_find_common_meeting_availability: "Checking common availability",
11
19
  seedclub_list_meetings: "Checking meeting schedule",
12
20
  seedclub_list_show_guests: "Looking up show guests",
13
21
  seedclub_list_guest_roster: "Building guest roster",
@@ -18,6 +26,9 @@ export const TOOL_CALL_LABELS: Record<string, string> = {
18
26
  seedclub_list_meeting_transcripts: "Checking transcript availability",
19
27
  seedclub_get_meeting_transcript: "Loading meeting transcript",
20
28
  seedclub_get_meeting: "Loading meeting details",
29
+ seedclub_book_meeting: "Booking meeting",
30
+ seedclub_reschedule_meeting: "Rescheduling meeting",
31
+ seedclub_cancel_meeting: "Cancelling meeting",
21
32
  seedclub_update_meeting: "Updating meeting",
22
33
  seedclub_assign_meeting: "Assigning meeting",
23
34
  seedclub_list_program_media_assets: "Checking media assets",
@@ -38,6 +49,14 @@ export const TOOL_SUCCESS_LABELS: Record<string, string> = {
38
49
  seedclub_create_crm_note: "CRM note saved",
39
50
  seedclub_create_crm_task: "CRM task created",
40
51
  seedclub_list_program_contacts: "Program contacts loaded",
52
+ seedclub_search_people: "People loaded",
53
+ seedclub_list_meeting_availability: "Availability loaded",
54
+ seedclub_list_meeting_calendars: "Booking calendars loaded",
55
+ seedclub_list_personal_calendar_events: "Personal calendar events loaded",
56
+ seedclub_create_personal_calendar_event: "Personal calendar event created",
57
+ seedclub_update_personal_calendar_event: "Personal calendar event updated",
58
+ seedclub_delete_personal_calendar_event: "Personal calendar event deleted",
59
+ seedclub_find_common_meeting_availability: "Common availability loaded",
41
60
  seedclub_list_meetings: "Meeting schedule loaded",
42
61
  seedclub_list_show_guests: "Show guests loaded",
43
62
  seedclub_list_guest_roster: "Guest roster ready",
@@ -48,6 +67,9 @@ export const TOOL_SUCCESS_LABELS: Record<string, string> = {
48
67
  seedclub_list_meeting_transcripts: "Transcript availability checked",
49
68
  seedclub_get_meeting_transcript: "Meeting transcript loaded",
50
69
  seedclub_get_meeting: "Meeting details loaded",
70
+ seedclub_book_meeting: "Meeting booked",
71
+ seedclub_reschedule_meeting: "Meeting rescheduled",
72
+ seedclub_cancel_meeting: "Meeting cancelled",
51
73
  seedclub_update_meeting: "Meeting updated",
52
74
  seedclub_assign_meeting: "Meeting assigned",
53
75
  seedclub_list_program_media_assets: "Media assets checked",
@@ -123,10 +123,4 @@ let lastCtx: ExtensionContext | undefined;
123
123
  lastCtx = ctx;
124
124
  refresh(ctx);
125
125
  });
126
-
127
- pi.on("session_switch", (_event, ctx) => {
128
- if (!ctx.hasUI) return;
129
- lastCtx = ctx;
130
- refresh(ctx);
131
- });
132
126
  }
@@ -1,5 +1,6 @@
1
1
  export const uiState = {
2
2
  ready: false,
3
+ requestRender: undefined as undefined | (() => void),
3
4
  todayOn11am: null as null | {
4
5
  date: string;
5
6
  guests: Array<{
@@ -10,4 +11,8 @@ export const uiState = {
10
11
  transcriptAvailable: boolean;
11
12
  }>;
12
13
  },
14
+ update: null as null | {
15
+ installedVersion: string;
16
+ latestVersion: string;
17
+ },
13
18
  };
@@ -1,19 +1,28 @@
1
1
  /**
2
- * Update checkruns on session start, notifies if a new version is available.
3
- * To update, run `seedclub update` in your terminal.
2
+ * Update promptchecks npm for a newer package version and offers actions.
4
3
  */
5
4
 
6
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
- import { readFileSync, existsSync } from "node:fs";
8
5
  import { execFile } from "node:child_process";
9
- import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { mkdir, writeFile } from "node:fs/promises";
10
8
  import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+ import { isAuthGateBlocking } from "../seedclub/gate-state.js";
12
+ import { uiState } from "./state.js";
11
13
 
12
14
  interface VersionInfo {
13
15
  seedclubVersion: string;
14
16
  piVersion: string;
15
17
  }
16
18
 
19
+ interface UpdatePrefs {
20
+ skipVersion?: string | null;
21
+ }
22
+
23
+ const UPDATE_PREFS_FILE = join(homedir(), ".seedclub", "agent", ".seedclub-update-prefs.json");
24
+ const UPDATE_COMMAND = "npm install -g @clubnet/seedclub@latest";
25
+
17
26
  function compareSemver(a: string, b: string): number {
18
27
  const pa = a.trim().split(".").map((x) => Number.parseInt(x, 10));
19
28
  const pb = b.trim().split(".").map((x) => Number.parseInt(x, 10));
@@ -38,9 +47,23 @@ function getInstalledVersion(): VersionInfo | null {
38
47
  }
39
48
  }
40
49
 
50
+ function getUpdatePrefs(): UpdatePrefs {
51
+ if (!existsSync(UPDATE_PREFS_FILE)) return {};
52
+ try {
53
+ return JSON.parse(readFileSync(UPDATE_PREFS_FILE, "utf-8"));
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ async function storeUpdatePrefs(prefs: UpdatePrefs): Promise<void> {
60
+ await mkdir(join(homedir(), ".seedclub", "agent"), { recursive: true });
61
+ await writeFile(UPDATE_PREFS_FILE, JSON.stringify(prefs, null, 2) + "\n");
62
+ }
63
+
41
64
  function getLatestVersion(): Promise<string | null> {
42
65
  return new Promise((resolve) => {
43
- execFile("npm", ["view", "@clubnet/seedclub", "version"], { timeout: 10000 }, (err, stdout) => {
66
+ execFile("npm", ["view", "@clubnet/seedclub", "version"], { timeout: 10_000 }, (err, stdout) => {
44
67
  if (err) return resolve(null);
45
68
  const version = stdout.trim();
46
69
  resolve(version || null);
@@ -48,24 +71,140 @@ function getLatestVersion(): Promise<string | null> {
48
71
  });
49
72
  }
50
73
 
74
+ async function waitForReadyWindow(): Promise<boolean> {
75
+ const startedAt = Date.now();
76
+ while (Date.now() - startedAt < 30_000) {
77
+ if (uiState.ready && !isAuthGateBlocking()) return true;
78
+ await new Promise((resolve) => setTimeout(resolve, 250));
79
+ }
80
+ return false;
81
+ }
82
+
83
+ async function runUpdateCommand(pi: ExtensionAPI): Promise<void> {
84
+ await new Promise<void>((resolve, reject) => {
85
+ execFile("npm", ["install", "-g", "@clubnet/seedclub@latest"], { timeout: 120_000 }, (error) => {
86
+ if (error) {
87
+ reject(error);
88
+ return;
89
+ }
90
+ resolve();
91
+ });
92
+ });
93
+
94
+ // Trigger a pi-side version refresh if available in the current runtime.
95
+ try {
96
+ await pi.exec("seedclub", ["version"]);
97
+ } catch {}
98
+ }
99
+
100
+ function setAvailableUpdate(installedVersion: string | null, latestVersion: string | null) {
101
+ if (installedVersion && latestVersion) {
102
+ uiState.update = { installedVersion, latestVersion };
103
+ } else {
104
+ uiState.update = null;
105
+ }
106
+ uiState.requestRender?.();
107
+ }
108
+
109
+ function notifyAvailableUpdate(ctx: any, latestVersion: string) {
110
+ ctx.ui.notify(`New update available (v${latestVersion}). Use /update to update seedclub.`, "info");
111
+ }
112
+
113
+ async function openUpdatePrompt(
114
+ pi: ExtensionAPI,
115
+ ctx: any,
116
+ installedVersion: string,
117
+ latestVersion: string,
118
+ ): Promise<void> {
119
+ const choice = await ctx.ui.select(`Update available (${installedVersion} -> ${latestVersion})`, [
120
+ `Update now (${UPDATE_COMMAND})`,
121
+ "Skip",
122
+ "Skip until next version",
123
+ ]);
124
+
125
+ switch (choice) {
126
+ case `Update now (${UPDATE_COMMAND})`:
127
+ ctx.ui.notify(`Updating seedclub...\nRunning: ${UPDATE_COMMAND}`, "info");
128
+ try {
129
+ await runUpdateCommand(pi);
130
+ await storeUpdatePrefs({ skipVersion: null });
131
+ setAvailableUpdate(null, null);
132
+ ctx.ui.notify("seedclub was updated. Restart the app to load the new version.", "info");
133
+ } catch (error) {
134
+ const message = error instanceof Error ? error.message : "Update failed.";
135
+ ctx.ui.notify(`Update failed: ${message}`, "error");
136
+ }
137
+ break;
138
+ case "Skip until next version":
139
+ await storeUpdatePrefs({ skipVersion: latestVersion });
140
+ setAvailableUpdate(null, null);
141
+ break;
142
+ default:
143
+ break;
144
+ }
145
+ }
146
+
51
147
  export default function (pi: ExtensionAPI) {
52
148
  let checked = false;
149
+ let announced = false;
53
150
 
54
- pi.on("session_start", async (_event, ctx) => {
151
+ pi.on("session_start", (_event, ctx) => {
55
152
  if (!ctx.hasUI || checked) return;
56
153
  checked = true;
154
+ announced = false;
155
+ setAvailableUpdate(null, null);
156
+
157
+ void (async () => {
158
+ const installed = getInstalledVersion();
159
+ if (!installed?.seedclubVersion) return;
160
+
161
+ const latest = await getLatestVersion();
162
+ if (!latest) return;
163
+ if (compareSemver(latest, installed.seedclubVersion) <= 0) return;
164
+
165
+ const prefs = getUpdatePrefs();
166
+ if (prefs.skipVersion === latest) return;
167
+
168
+ if (isAuthGateBlocking()) {
169
+ const ready = await waitForReadyWindow();
170
+ if (!ready) return;
171
+ }
172
+ setAvailableUpdate(installed.seedclubVersion, latest);
173
+ if (!announced) {
174
+ notifyAvailableUpdate(ctx, latest);
175
+ announced = true;
176
+ }
177
+ })().catch(() => {});
178
+ });
179
+
180
+ pi.registerCommand("update", {
181
+ description: "Show the seedclub package update menu",
182
+ handler: async (_args, ctx) => {
183
+ const installed = getInstalledVersion();
184
+ if (!installed?.seedclubVersion) {
185
+ ctx.ui.notify("Unable to determine the installed seedclub version.", "error");
186
+ return;
187
+ }
57
188
 
58
- const installed = getInstalledVersion();
59
- if (!installed?.seedclubVersion) return;
189
+ const latest = await getLatestVersion();
190
+ if (!latest) {
191
+ ctx.ui.notify("Unable to check npm for the latest seedclub version.", "error");
192
+ return;
193
+ }
60
194
 
61
- const latest = await getLatestVersion();
62
- if (!latest) return;
195
+ if (compareSemver(latest, installed.seedclubVersion) <= 0) {
196
+ setAvailableUpdate(null, null);
197
+ await storeUpdatePrefs({ skipVersion: null });
198
+ ctx.ui.notify(`seedclub is up to date (v${installed.seedclubVersion}).`, "info");
199
+ return;
200
+ }
63
201
 
64
- if (compareSemver(latest, installed.seedclubVersion) <= 0) return;
202
+ const prefs = getUpdatePrefs();
203
+ if (prefs.skipVersion !== latest) {
204
+ setAvailableUpdate(installed.seedclubVersion, latest);
205
+ }
65
206
 
66
- ctx.ui.notify(
67
- `Update available: seedclub v${latest} is out (installed: v${installed.seedclubVersion}). Run \`seedclub update\`.`,
68
- "info",
69
- );
207
+ await openUpdatePrompt(pi, ctx, installed.seedclubVersion, latest);
208
+ },
70
209
  });
71
210
  }
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Welcome header with live weather + market data.
3
- * /commands and /extensions are available as commands.
3
+ * /extensions is available as a command.
4
4
  */
5
5
 
6
6
  import { execFileSync } from "node:child_process";
7
- import { basename } from "node:path";
7
+ import { existsSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, join } from "node:path";
8
10
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
11
  import { ApiError, NotConnectedError, api } from "../seedclub/api-client.js";
10
12
  import { getAuthGateState, isAuthGateBlocking, subscribeToAuthGate } from "../seedclub/gate-state.js";
@@ -311,6 +313,15 @@ function renderSetupLines(setupHints: string[], theme: ThemeLike): string[] {
311
313
  });
312
314
  }
313
315
 
316
+ function renderUpdateLines(theme: ThemeLike): string[] {
317
+ const update = uiState.update;
318
+ if (!update) return [];
319
+ return [
320
+ ` ${theme.fg("accent", "New update available")}`,
321
+ ` ${theme.fg("text", "/update")} ${theme.fg("dim", "open the package update menu")}`,
322
+ ];
323
+ }
324
+
314
325
  function renderAuthGateLines(theme: ThemeLike): string[] {
315
326
  const gate = getAuthGateState();
316
327
  const lines = [
@@ -334,11 +345,42 @@ function renderAuthGateLines(theme: ThemeLike): string[] {
334
345
 
335
346
  lines.push("");
336
347
  lines.push(` ${theme.fg("text", "/connect")} ${theme.fg("dim", "retry sign-in")}`);
337
- lines.push(` ${theme.fg("text", "/commands")} ${theme.fg("dim", "list commands available during setup")}`);
338
348
  lines.push("");
339
349
  return lines;
340
350
  }
341
351
 
352
+ function renderAuthCheckingLines(theme: ThemeLike, frame: number): string[] {
353
+ const gate = getAuthGateState();
354
+ const lines = [
355
+ "",
356
+ renderTitle(theme),
357
+ "",
358
+ ...renderCoinLoaderLines(frame, theme, { showLabel: false }),
359
+ "",
360
+ ` ${theme.fg("accent", "Checking access")}`,
361
+ ` ${theme.fg("dim", gate.message || "Checking Seed Club access...")}`,
362
+ "",
363
+ ];
364
+
365
+ if (gate.error) {
366
+ lines.push(` ${theme.fg("error", gate.error)}`);
367
+ lines.push("");
368
+ }
369
+
370
+ if (gate.authUrl) {
371
+ lines.push(` ${theme.fg("text", "Auth URL")}`);
372
+ lines.push(` ${theme.fg("mdLink", gate.authUrl)}`);
373
+ lines.push("");
374
+ }
375
+
376
+ return lines;
377
+ }
378
+
379
+ function hasSeedclubCredential(): boolean {
380
+ if (process.env.SEEDCLUB_ACCESS_TOKEN?.trim()) return true;
381
+ return existsSync(join(homedir(), ".config", "seedclub", "token"));
382
+ }
383
+
342
384
  function renderTodayOn11amLines(today: TodayOn11am | null, theme: ThemeLike): string[] {
343
385
  if (!today || !today.guests.length) return [];
344
386
  const lines = [` ${theme.fg("text", "Today on 11AM")}`];
@@ -354,7 +396,7 @@ function renderTodayOn11amLines(today: TodayOn11am | null, theme: ThemeLike): st
354
396
  return lines;
355
397
  }
356
398
 
357
- function renderCoinLoaderLines(frame: number, theme: ThemeLike): string[] {
399
+ function renderCoinLoaderLines(frame: number, theme: ThemeLike, options?: { showLabel?: boolean }): string[] {
358
400
  const rawShape = [
359
401
  " XXXXXX XXXXXX",
360
402
  " XXXXXX XXXXXX",
@@ -410,6 +452,9 @@ function renderCoinLoaderLines(frame: number, theme: ThemeLike): string[] {
410
452
 
411
453
  const loadingLabel = "loading...";
412
454
  const loadingIndent = " ".repeat(2 + Math.max(0, Math.floor((cols - loadingLabel.length) / 2)));
455
+ if (options?.showLabel === false) {
456
+ return lines;
457
+ }
413
458
  return [...lines, "", `${loadingIndent}${theme.fg("dim", loadingLabel)}`];
414
459
  }
415
460
 
@@ -434,8 +479,15 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
434
479
  unsubscribeAuthGate?.();
435
480
 
436
481
  let tuiRef: any = null;
482
+ let animatedHeaderTimer: ReturnType<typeof setInterval> | undefined;
483
+ let animatedHeaderFrame = 0;
484
+ let repaintHeader: (() => void) | undefined;
437
485
  ctx.ui.setHeader((tui, theme) => {
438
486
  tuiRef = tui;
487
+ uiState.requestRender = () => {
488
+ repaintHeader?.();
489
+ tui.requestRender();
490
+ };
439
491
  tui.setClearOnShrink(true);
440
492
 
441
493
  // Enable window frame around entire TUI (footer excluded)
@@ -456,6 +508,31 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
456
508
  };
457
509
  });
458
510
 
511
+ const stopAnimatedHeader = () => {
512
+ if (!animatedHeaderTimer) return;
513
+ clearInterval(animatedHeaderTimer);
514
+ animatedHeaderTimer = undefined;
515
+ };
516
+
517
+ const startAnimatedHeader = (renderFrame: (frame: number) => string[]) => {
518
+ stopAnimatedHeader();
519
+ animatedHeaderFrame = 0;
520
+ repaintHeader = () => {
521
+ headerLines = renderFrame(animatedHeaderFrame);
522
+ };
523
+ const render = () => {
524
+ headerLines = renderFrame(animatedHeaderFrame);
525
+ };
526
+ render();
527
+ tuiRef?.requestRender();
528
+ animatedHeaderTimer = setInterval(() => {
529
+ animatedHeaderFrame += 1;
530
+ render();
531
+ tuiRef?.requestRender();
532
+ }, 120);
533
+ animatedHeaderTimer.unref?.();
534
+ };
535
+
459
536
  let loadStarted = false;
460
537
 
461
538
  const startReadyHeader = () => {
@@ -470,33 +547,23 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
470
547
  if (!hasSelectedModel) setupHints.push("/model");
471
548
  const setupLines = renderSetupLines(setupHints, ctx.ui.theme);
472
549
 
473
- let loaderFrame = 0;
474
- const renderLoadingHeader = () => {
475
- headerLines = [
476
- "",
477
- renderTitle(ctx.ui.theme),
478
- "",
479
- ...renderCoinLoaderLines(loaderFrame, ctx.ui.theme),
480
- "",
481
- ];
482
- };
483
- renderLoadingHeader();
484
- const loaderTimer = setInterval(() => {
485
- loaderFrame += 1;
486
- renderLoadingHeader();
487
- tuiRef?.requestRender();
488
- }, 120);
489
- loaderTimer.unref?.();
490
- tuiRef?.requestRender();
550
+ startAnimatedHeader((frame) => [
551
+ "",
552
+ renderTitle(ctx.ui.theme),
553
+ "",
554
+ ...renderCoinLoaderLines(frame, ctx.ui.theme),
555
+ "",
556
+ ]);
491
557
 
492
558
  const todayPromise = fetchTodayOn11am();
493
559
  void Promise.all([
494
560
  getData(),
495
561
  withTimeout(todayPromise, TODAY_PREFETCH_TIMEOUT_MS, null),
496
562
  ]).then(([{ weather, market }, todayOn11am]) => {
497
- clearInterval(loaderTimer);
563
+ stopAnimatedHeader();
498
564
  const renderReadyHeader = (today: TodayOn11am | null) => {
499
565
  const theme = ctx.ui.theme;
566
+ const updateLines = renderUpdateLines(theme);
500
567
  const weatherLine = ` ${weather.icon} ${theme.fg("text", weather.temp)} ${theme.fg("dim", weather.condition)} ${theme.fg("dim", "·")} ${theme.fg("dim", weather.location)}`;
501
568
  const marketLine = ` ${market.map((quote) => formatQuote(quote, theme)).join(` ${theme.fg("dim", "·")} `)}`;
502
569
  const todayLines = renderTodayOn11amLines(today, theme);
@@ -508,12 +575,16 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
508
575
  weatherLine,
509
576
  marketLine,
510
577
  "",
578
+ ...updateLines,
579
+ ...(updateLines.length ? [""] : []),
511
580
  ...todayLines,
512
581
  ...(todayLines.length ? [""] : []),
513
582
  ...setupLines,
583
+ ...(setupLines.length ? [""] : []),
514
584
  "",
515
585
  ];
516
586
  };
587
+ repaintHeader = () => renderReadyHeader(uiState.todayOn11am);
517
588
 
518
589
  renderReadyHeader(todayOn11am);
519
590
  uiState.ready = true;
@@ -528,14 +599,21 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
528
599
  }).catch(() => {});
529
600
  }
530
601
  }).catch(() => {
531
- clearInterval(loaderTimer);
532
- headerLines = [
533
- "",
534
- renderTitle(ctx.ui.theme),
535
- "",
536
- ...setupLines,
537
- "",
538
- ];
602
+ stopAnimatedHeader();
603
+ repaintHeader = () => {
604
+ const updateLines = renderUpdateLines(ctx.ui.theme);
605
+ headerLines = [
606
+ "",
607
+ renderTitle(ctx.ui.theme),
608
+ "",
609
+ ...updateLines,
610
+ ...(updateLines.length ? [""] : []),
611
+ ...setupLines,
612
+ ...(setupLines.length ? [""] : []),
613
+ "",
614
+ ];
615
+ };
616
+ repaintHeader();
539
617
  uiState.ready = true;
540
618
  ctx.ui.setEditorText("");
541
619
  tuiRef?.requestRender();
@@ -543,10 +621,28 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
543
621
  };
544
622
 
545
623
  const renderCurrentHeader = () => {
624
+ const gate = getAuthGateState();
625
+ if (gate.status === "auth_in_progress") {
626
+ if (hasSeedclubCredential()) {
627
+ startReadyHeader();
628
+ return;
629
+ }
630
+ loadStarted = false;
631
+ uiState.todayOn11am = null;
632
+ startAnimatedHeader((frame) => renderAuthCheckingLines(ctx.ui.theme, frame));
633
+ uiState.ready = true;
634
+ ctx.ui.setEditorText("");
635
+ return;
636
+ }
637
+
546
638
  if (isAuthGateBlocking()) {
639
+ stopAnimatedHeader();
547
640
  loadStarted = false;
548
641
  uiState.todayOn11am = null;
549
- headerLines = renderAuthGateLines(ctx.ui.theme);
642
+ repaintHeader = () => {
643
+ headerLines = renderAuthGateLines(ctx.ui.theme);
644
+ };
645
+ repaintHeader();
550
646
  uiState.ready = true;
551
647
  ctx.ui.setEditorText("");
552
648
  tuiRef?.requestRender();
@@ -571,10 +667,6 @@ export default function (pi: ExtensionAPI, options?: { enableFrame?: boolean })
571
667
  applyTerminalTitle(ctx);
572
668
  });
573
669
 
574
- pi.on("session_switch", (_event, ctx) => {
575
- applyTerminalTitle(ctx);
576
- });
577
-
578
670
  pi.on("before_agent_start", async (event) => {
579
671
  const today = uiState.todayOn11am;
580
672
  if (!today?.guests.length) return;
@@ -627,8 +719,9 @@ ${rows.join("\n")}`,
627
719
  const commands = pi.getCommands();
628
720
  const extPaths = new Map<string, string[]>();
629
721
  for (const cmd of commands) {
630
- if (cmd.source === "extension" && cmd.path) {
631
- const name = cmd.path.replace(/.*\/extensions\//, "").replace(/\.ts$/, "");
722
+ const cmdPath = cmd.sourceInfo?.path;
723
+ if (cmd.source === "extension" && cmdPath) {
724
+ const name = cmdPath.replace(/.*\/extensions\//, "").replace(/\.ts$/, "");
632
725
  if (!extPaths.has(name)) extPaths.set(name, []);
633
726
  extPaths.get(name)!.push(cmd.name);
634
727
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubnet/seedclub",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
4
4
  "description": "A branded command-line agent wrapper around pi, with integrated Seed Club commands, tools, and app actions",
5
5
  "license": "MIT",
6
6
  "repository": {