History

Add menubar app support and update related configurations

- Introduced a new Electron-based menubar application for pending task approvals in the executor. - Updated `package.json` and `assistant/package.json` to include development scripts for the menubar app. - Enhanced the README to document the new menubar app and its purpose. - Removed legacy web directory references from the uninstall script to streamline the uninstallation process.

Rhys Sullivan committed 9 months ago

Commit f751cc71 parent 7f488d513 files · +640 −19Browse files

Showing the changes to executor (10 of 13 files). Show all

Unified · Split

10 files
  1. executor/README.md +1 −0
  2. executor/apps/menubar/.gitignore +1 −0
  3. executor/apps/menubar/README.md +42 −0
  4. executor/apps/menubar/package.json +21 −0
  5. executor/apps/menubar/src/main.ts +411 −0
  6. executor/apps/menubar/tsconfig.json +16 −0
  7. executor/apps/web/src/components/tool-explorer.tsx +2 −2
  8. executor/convex/auth.config.ts +10 −1
  9. executor/lib/managed_runtime.ts +7 −11
  10. executor/uninstall +0 −3
executor/README.md+1−0
@@ -75,6 +75,7 @@ Artifacts are written to `dist/release/`. · 74 unmodified lines
7575 - `convex/`: executor control plane, MCP HTTP endpoint, task execution/actions, policies, credentials, approvals, and persistence.
7676 - `lib/`: runtime, MCP server helpers, typechecker, tool loading/discovery utilities.
7777 - `apps/web`: executor web UI for approvals, task history, and settings.
78+- `apps/menubar`: Electron-based macOS menubar spike for pending task approvals.
7879 - `packages/contracts`: shared task/tool/policy contract types.
7980
8081 ## Notes
executor/apps/menubar/.gitignore+1−0
@@ -0,0 +1 @@
1+dist
executor/apps/menubar/README.md+42−0
@@ -0,0 +1,42 @@
1+# Executor macOS Menubar (Spike)
2+
3+This app is an initial Electron-based menubar/tray client for reviewing and resolving pending Executor approvals.
4+
5+It polls `workspace.listPendingApprovals`, displays pending approvals in a tray menu, and calls `executor.resolveApproval` for Approve/Deny actions.
6+
7+## Why this shape
8+
9+- Uses existing Convex APIs, so no backend changes were required.
10+- Works with anonymous local sessions (`workspace.bootstrapAnonymousSession`) and bearer-token auth.
11+- Keeps the first iteration small so we can validate approval workflow ergonomics before investing in native packaging.
12+
13+## Configuration
14+
15+Set these environment variables before launching:
16+
17+- `CONVEX_URL` (required): Convex deployment URL.
18+- `EXECUTOR_WORKSPACE_ID` (optional): workspace to monitor. If omitted, the app resolves a workspace automatically.
19+- `EXECUTOR_SESSION_ID` (optional): session for anonymous/local usage.
20+- `EXECUTOR_AUTH_TOKEN` (optional): bearer token for WorkOS-authenticated usage.
21+- `EXECUTOR_POLL_INTERVAL_MS` (optional): polling interval, minimum `1000`, default `5000`.
22+
23+If `EXECUTOR_AUTH_TOKEN` is not set, the app bootstraps an anonymous session automatically.
24+
25+## Run
26+
27+```bash
28+bun run --cwd executor/apps/menubar dev
29+```
30+
31+## Current limitations
32+
33+- Poll-based updates (no subscription stream yet).
34+- No sign-in UI yet; auth/session values are passed through env vars.
35+- No app signing/notarization/package pipeline yet for distribution.
36+
37+## Next production steps
38+
39+1. Replace polling with Convex live subscriptions or an event stream.
40+2. Add in-app auth flow (WorkOS OAuth/device flow) and secure local token storage.
41+3. Add macOS packaging (`.app`), code signing, and notarization automation.
42+4. Add richer approval detail view (input payload and tool metadata) in a popover window.
executor/apps/menubar/package.json+21−0
@@ -0,0 +1,21 @@
1+{
2+ "name": "@executor/menubar",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "build": "bun build src/main.ts --target=node --format=esm --external electron --outfile dist/main.js",
8+ "dev": "bun run build && bunx electron dist/main.js",
9+ "start": "bun run dev",
10+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
11+ },
12+ "dependencies": {
13+ "@executor/convex": "workspace:*",
14+ "convex": "^1.31.7",
15+ "electron": "^37.2.0"
16+ },
17+ "devDependencies": {
18+ "@types/node": "^20",
19+ "typescript": "^5"
20+ }
21+}
executor/apps/menubar/src/main.ts+411−0
@@ -0,0 +1,411 @@
1+import { app, Menu, Notification, Tray, nativeImage, type MenuItemConstructorOptions } from "electron";
2+import { ConvexHttpClient } from "convex/browser";
3+import { api } from "@executor/convex/_generated/api";
4+import type { Id } from "@executor/convex/_generated/dataModel";
5+
6+type ApprovalDecision = "approved" | "denied";
7+
8+type PendingApprovalRecord = {
9+ id: string;
10+ taskId: string;
11+ toolPath: string;
12+ createdAt: number;
13+};
14+
15+type WorkspaceListItem = {
16+ id: Id<"workspaces">;
17+ name: string;
18+};
19+
20+type AnonymousContext = {
21+ sessionId: string;
22+ workspaceId: Id<"workspaces">;
23+};
24+
25+type RuntimeConfig = {
26+ convexUrl: string;
27+ workspaceId?: Id<"workspaces">;
28+ sessionId?: string;
29+ authToken?: string;
30+ pollIntervalMs: number;
31+};
32+
33+type RuntimeContext = {
34+ workspaceId: Id<"workspaces">;
35+ sessionId?: string;
36+};
37+
38+const DEFAULT_POLL_INTERVAL_MS = 5_000;
39+
40+const DEFAULT_ICON_DATA_URL =
41+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/6ioAAAAASUVORK5CYII=";
42+
43+function parseWorkspaceId(value: string | undefined): Id<"workspaces"> | undefined {
44+ if (!value) {
45+ return undefined;
46+ }
47+ const trimmed = value.trim();
48+ return trimmed.length > 0 ? (trimmed as Id<"workspaces">) : undefined;
49+}
50+
51+function parseOptionalString(value: string | undefined): string | undefined {
52+ if (!value) {
53+ return undefined;
54+ }
55+ const trimmed = value.trim();
56+ return trimmed.length > 0 ? trimmed : undefined;
57+}
58+
59+function parsePollInterval(value: string | undefined): number {
60+ if (!value) {
61+ return DEFAULT_POLL_INTERVAL_MS;
62+ }
63+ const parsed = Number.parseInt(value, 10);
64+ if (Number.isNaN(parsed) || parsed < 1_000) {
65+ throw new Error("EXECUTOR_POLL_INTERVAL_MS must be a number >= 1000");
66+ }
67+ return parsed;
68+}
69+
70+function readConfigFromEnv(): RuntimeConfig {
71+ const convexUrl = parseOptionalString(process.env.CONVEX_URL);
72+ if (!convexUrl) {
73+ throw new Error("CONVEX_URL is required");
74+ }
75+
76+ return {
77+ convexUrl,
78+ workspaceId: parseWorkspaceId(process.env.EXECUTOR_WORKSPACE_ID),
79+ sessionId: parseOptionalString(process.env.EXECUTOR_SESSION_ID),
80+ authToken: parseOptionalString(process.env.EXECUTOR_AUTH_TOKEN),
81+ pollIntervalMs: parsePollInterval(process.env.EXECUTOR_POLL_INTERVAL_MS),
82+ };
83+}
84+
85+function truncate(value: string, maxLength: number): string {
86+ if (value.length <= maxLength) {
87+ return value;
88+ }
89+ if (maxLength <= 1) {
90+ return value.slice(0, maxLength);
91+ }
92+ return `${value.slice(0, maxLength - 1)}…`;
93+}
94+
95+function formatTimeAgo(timestamp: number): string {
96+ const elapsedMs = Date.now() - timestamp;
97+ if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
98+ return "now";
99+ }
100+
101+ const elapsedSec = Math.floor(elapsedMs / 1_000);
102+ if (elapsedSec < 60) {
103+ return `${elapsedSec}s ago`;
104+ }
105+
106+ const elapsedMin = Math.floor(elapsedSec / 60);
107+ if (elapsedMin < 60) {
108+ return `${elapsedMin}m ago`;
109+ }
110+
111+ const elapsedHours = Math.floor(elapsedMin / 60);
112+ if (elapsedHours < 24) {
113+ return `${elapsedHours}h ago`;
114+ }
115+
116+ const elapsedDays = Math.floor(elapsedHours / 24);
117+ return `${elapsedDays}d ago`;
118+}
119+
120+function isMacOS(): boolean {
121+ return process.platform === "darwin";
122+}
123+
124+class ApprovalMenubarApp {
125+ private readonly convex: ConvexHttpClient;
126+ private readonly inFlightApprovalIds = new Set<string>();
127+ private readonly seenApprovalIds = new Set<string>();
128+ private readonly icon = nativeImage.createFromDataURL(DEFAULT_ICON_DATA_URL);
129+ private context: RuntimeContext | null = null;
130+ private tray: Tray | null = null;
131+ private approvals: PendingApprovalRecord[] = [];
132+ private pollingTimer: ReturnType<typeof setInterval> | null = null;
133+ private refreshInFlight = false;
134+ private lastError: string | null = null;
135+
136+ constructor(private readonly config: RuntimeConfig) {
137+ this.convex = new ConvexHttpClient(config.convexUrl);
138+ if (config.authToken) {
139+ this.convex.setAuth(config.authToken);
140+ }
141+ }
142+
143+ async start(): Promise<void> {
144+ this.context = await this.resolveContext();
145+ this.tray = new Tray(this.icon);
146+ this.tray.setToolTip("Executor task approvals");
147+
148+ if (isMacOS()) {
149+ this.tray.setTitle("Exec");
150+ }
151+
152+ this.updateMenu();
153+ await this.refreshApprovals({ initial: true });
154+
155+ this.pollingTimer = setInterval(() => {
156+ void this.refreshApprovals({ initial: false });
157+ }, this.config.pollIntervalMs);
158+ }
159+
160+ stop(): void {
161+ if (this.pollingTimer) {
162+ clearInterval(this.pollingTimer);
163+ this.pollingTimer = null;
164+ }
165+ }
166+
167+ private async resolveContext(): Promise<RuntimeContext> {
168+ let sessionId = this.config.sessionId;
169+ let workspaceId = this.config.workspaceId;
170+
171+ if (!this.config.authToken) {
172+ const bootstrapped = await this.convex.mutation(api.workspace.bootstrapAnonymousSession, {
173+ sessionId,
174+ }) as AnonymousContext;
175+
176+ sessionId = bootstrapped.sessionId;
177+ if (!workspaceId) {
178+ workspaceId = bootstrapped.workspaceId;
179+ }
180+ }
181+
182+ if (!workspaceId) {
183+ const workspaces = await this.convex.query(api.workspaces.list, {
184+ sessionId,
185+ }) as WorkspaceListItem[];
186+
187+ workspaceId = workspaces[0]?.id;
188+ }
189+
190+ if (!workspaceId) {
191+ throw new Error(
192+ "Could not determine workspace. Set EXECUTOR_WORKSPACE_ID or provide session/auth that can access a workspace.",
193+ );
194+ }
195+
196+ return {
197+ workspaceId,
198+ sessionId,
199+ };
200+ }
201+
202+ private async refreshApprovals(options: { initial: boolean }): Promise<void> {
203+ if (!this.context || this.refreshInFlight) {
204+ return;
205+ }
206+
207+ this.refreshInFlight = true;
208+ try {
209+ const approvals = await this.convex.query(api.workspace.listPendingApprovals, {
210+ workspaceId: this.context.workspaceId,
211+ sessionId: this.context.sessionId,
212+ }) as PendingApprovalRecord[];
213+
214+ this.lastError = null;
215+ this.handleNotifications(approvals, options.initial);
216+ this.approvals = approvals;
217+ } catch (error) {
218+ this.lastError = error instanceof Error ? error.message : "Failed to load approvals";
219+ } finally {
220+ this.refreshInFlight = false;
221+ this.updateMenu();
222+ }
223+ }
224+
225+ private handleNotifications(nextApprovals: PendingApprovalRecord[], initial: boolean): void {
226+ const currentIds = new Set(nextApprovals.map((approval) => approval.id));
227+
228+ if (initial) {
229+ this.seenApprovalIds.clear();
230+ currentIds.forEach((id) => this.seenApprovalIds.add(id));
231+ return;
232+ }
233+
234+ const newApprovals = nextApprovals.filter((approval) => !this.seenApprovalIds.has(approval.id));
235+ this.seenApprovalIds.clear();
236+ currentIds.forEach((id) => this.seenApprovalIds.add(id));
237+
238+ if (newApprovals.length === 0) {
239+ return;
240+ }
241+
242+ const newest = newApprovals[newApprovals.length - 1];
243+ const body = newApprovals.length === 1
244+ ? `Task ${newest.taskId} is waiting on ${newest.toolPath}`
245+ : `${newApprovals.length} approvals are waiting for review`;
246+
247+ new Notification({
248+ title: "Executor approval required",
249+ body,
250+ silent: false,
251+ }).show();
252+ }
253+
254+ private setTrayTitle(count: number): void {
255+ if (!this.tray) {
256+ return;
257+ }
258+
259+ const label = count > 0 ? `Exec ${count}` : "Exec";
260+ if (isMacOS()) {
261+ this.tray.setTitle(label);
262+ }
263+ this.tray.setToolTip(`Executor approvals (${count} pending)`);
264+ }
265+
266+ private updateMenu(): void {
267+ if (!this.tray || !this.context) {
268+ return;
269+ }
270+
271+ this.setTrayTitle(this.approvals.length);
272+
273+ const menuTemplate: MenuItemConstructorOptions[] = [
274+ {
275+ label: `Workspace: ${this.context.workspaceId}`,
276+ enabled: false,
277+ },
278+ {
279+ label: `${this.approvals.length} pending approval${this.approvals.length === 1 ? "" : "s"}`,
280+ enabled: false,
281+ },
282+ ];
283+
284+ if (this.lastError) {
285+ menuTemplate.push(
286+ { type: "separator" },
287+ {
288+ label: `Error: ${truncate(this.lastError, 64)}`,
289+ enabled: false,
290+ },
291+ );
292+ }
293+
294+ menuTemplate.push({ type: "separator" });
295+
296+ if (this.approvals.length === 0) {
297+ menuTemplate.push({
298+ label: "No pending approvals",
299+ enabled: false,
300+ });
301+ } else {
302+ this.approvals.forEach((approval) => {
303+ const isResolving = this.inFlightApprovalIds.has(approval.id);
304+ menuTemplate.push({
305+ label: truncate(`${approval.toolPath} (${formatTimeAgo(approval.createdAt)})`, 56),
306+ submenu: [
307+ {
308+ label: `Task: ${approval.taskId}`,
309+ enabled: false,
310+ },
311+ {
312+ label: `Approval ID: ${approval.id}`,
313+ enabled: false,
314+ },
315+ {
316+ type: "separator",
317+ },
318+ {
319+ label: isResolving ? "Approving..." : "Approve",
320+ enabled: !isResolving,
321+ click: () => {
322+ void this.resolveApproval(approval.id, "approved");
323+ },
324+ },
325+ {
326+ label: isResolving ? "Denying..." : "Deny",
327+ enabled: !isResolving,
328+ click: () => {
329+ void this.resolveApproval(approval.id, "denied");
330+ },
331+ },
332+ ],
333+ });
334+ });
335+ }
336+
337+ menuTemplate.push(
338+ { type: "separator" },
339+ {
340+ label: "Refresh now",
341+ click: () => {
342+ void this.refreshApprovals({ initial: false });
343+ },
344+ },
345+ {
346+ label: "Quit",
347+ role: "quit",
348+ },
349+ );
350+
351+ this.tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
352+ }
353+
354+ private async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> {
355+ if (!this.context || this.inFlightApprovalIds.has(approvalId)) {
356+ return;
357+ }
358+
359+ this.inFlightApprovalIds.add(approvalId);
360+ this.updateMenu();
361+
362+ try {
363+ await this.convex.mutation(api.executor.resolveApproval, {
364+ workspaceId: this.context.workspaceId,
365+ sessionId: this.context.sessionId,
366+ approvalId,
367+ decision,
368+ });
369+
370+ new Notification({
371+ title: decision === "approved" ? "Approval approved" : "Approval denied",
372+ body: `${approvalId} was ${decision}`,
373+ silent: false,
374+ }).show();
375+
376+ await this.refreshApprovals({ initial: false });
377+ } catch (error) {
378+ this.lastError = error instanceof Error ? error.message : "Failed to resolve approval";
379+ new Notification({
380+ title: "Executor approval action failed",
381+ body: this.lastError,
382+ silent: false,
383+ }).show();
384+ } finally {
385+ this.inFlightApprovalIds.delete(approvalId);
386+ this.updateMenu();
387+ }
388+ }
389+}
390+
391+let approvalApp: ApprovalMenubarApp | null = null;
392+
393+app.whenReady().then(async () => {
394+ if (isMacOS()) {
395+ app.dock?.hide();
396+ }
397+
398+ const config = readConfigFromEnv();
399+ approvalApp = new ApprovalMenubarApp(config);
400+ await approvalApp.start();
401+}).catch((error) => {
402+ const message = error instanceof Error ? error.message : String(error);
403+ console.error(`[executor-menubar] ${message}`);
404+ app.quit();
405+});
406+
407+app.on("window-all-closed", () => {});
408+
409+app.on("before-quit", () => {
410+ approvalApp?.stop();
411+});
executor/apps/menubar/tsconfig.json+16−0
@@ -0,0 +1,16 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "types": [
10+ "node"
11+ ]
12+ },
13+ "include": [
14+ "src/**/*.ts"
15+ ]
16+}
executor/apps/web/src/components/tool-explorer.tsx+2−2
@@ -369,8 +369,8 @@ function TypeSignature({ raw, label }: { raw: string; label: string }) { · 368 unmodified lines
369369 // ── Inline tool detail (shown when tool row is expanded) ──
370370
371371 function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {
372− // Align with the tool name text: depth indent + row paddingLeft(8) + checkbox(16) + gap(8) + chevron(16) + gap(8) + zap(12) + gap(8) = 76
373− const insetLeft = depth * 20 + 8 + 76;
372+ // Align details under the expand chevron.
373+ const insetLeft = depth * 20 + 8 + 16 + 8;
374374 return (
375375 <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}>
376376 {/* Description rendered as markdown/HTML via Streamdown */}
executor/convex/auth.config.ts+10−1
@@ -1,4 +1,13 @@
1−const clientId = process.env.WORKOS_CLIENT_ID;
1+function readEnvVar(name: string): string | undefined {
2+ for (const [key, value] of Object.entries(process.env)) {
3+ if (key === name) {
4+ return value;
5+ }
6+ }
7+ return undefined;
8+}
9+
10+const clientId = readEnvVar("WORKOS_CLIENT_ID");
211
312 const authConfig = clientId
413 ? {
executor/lib/managed_runtime.ts+7−11
@@ -412,12 +412,18 @@ async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<str · 411 unmodified lines
412412 }
413413
414414 async function findProjectDir(): Promise<string | null> {
415− const candidates = [
415+ const roots = [
416416 Bun.env.EXECUTOR_PROJECT_DIR,
417417 process.cwd(),
418418 path.resolve(import.meta.dir, ".."),
419419 ].filter((value): value is string => Boolean(value && value.trim().length > 0));
420420
421+ const candidates: string[] = [];
422+ for (const root of roots) {
423+ candidates.push(root);
424+ candidates.push(path.join(root, "executor"));
425+ }
426+
421427 for (const candidate of candidates) {
422428 const convexDir = path.join(candidate, "convex");
423429 const convexConfig = path.join(convexDir, "convex.config.ts");
@@ -537,16 +543,6 @@ async function ensureWebBundle(info: ManagedRuntimeInfo): Promise<void> { · 113 unmodified lines
537543 return;
538544 }
539545
540− const legacyWebDir = path.join(os.homedir(), ".executor", "web");
541− const legacyWebEntry = path.join(legacyWebDir, "server.js");
542− if (await pathExists(legacyWebEntry)) {
543− console.log(`[executor] using existing legacy web bundle at ${legacyWebDir}`);
544− await fs.rm(info.webDir, { recursive: true, force: true });
545− await fs.mkdir(path.dirname(info.webDir), { recursive: true });
546− await fs.cp(legacyWebDir, info.webDir, { recursive: true });
547− return;
548− }
549−
550546 const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-web-bundle-"));
551547 const archivePath = path.join(tempDir, info.webArtifactName);
552548 const localFallbackArchive = path.resolve(import.meta.dir, "..", "dist", "release", info.webArtifactName);
executor/uninstall+0−3
@@ -3,7 +3,6 @@ set -euo pipefail · 2 unmodified lines
33
44 INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
55 RUNTIME_DIR="${EXECUTOR_RUNTIME_DIR:-$HOME/.executor/runtime}"
6−LEGACY_WEB_DIR="${EXECUTOR_LEGACY_WEB_DIR:-$HOME/.executor/web}"
76 EXECUTOR_HOME="${EXECUTOR_HOME_DIR:-$HOME/.executor}"
87 ASSUME_YES="0"
98
@@ -41,7 +40,6 @@ if [[ "$ASSUME_YES" != "1" ]]; then · 31 unmodified lines
4140 echo "This will remove:"
4241 echo " - $INSTALL_DIR/executor"
4342 echo " - $RUNTIME_DIR"
44− echo " - $LEGACY_WEB_DIR"
4543 echo ""
4644 read -r -p "Continue? [y/N] " response
4745 if [[ ! "$response" =~ ^[Yy]$ ]]; then
@@ -52,7 +50,6 @@ fi · 4 unmodified lines
5250
5351 rm -f "$INSTALL_DIR/executor"
5452 rm -rf "$RUNTIME_DIR"
55−rm -rf "$LEGACY_WEB_DIR"
5653
5754 if [[ -d "$INSTALL_DIR" ]]; then
5855 rmdir "$INSTALL_DIR" 2>/dev/null || true