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
75- `convex/`: executor control plane, MCP HTTP endpoint, task execution/actions, policies, credentials, approvals, and persistence.75- `convex/`: executor control plane, MCP HTTP endpoint, task execution/actions, policies, credentials, approvals, and persistence.
76- `lib/`: runtime, MCP server helpers, typechecker, tool loading/discovery utilities.76- `lib/`: runtime, MCP server helpers, typechecker, tool loading/discovery utilities.
77- `apps/web`: executor web UI for approvals, task history, and settings.77- `apps/web`: executor web UI for approvals, task history, and settings.
78- `apps/menubar`: Electron-based macOS menubar spike for pending task approvals.
78- `packages/contracts`: shared task/tool/policy contract types.79- `packages/contracts`: shared task/tool/policy contract types.
79 80
80## Notes81## Notes
executor/apps/menubar/.gitignore+1−0
@@ -0,0 +1 @@
1dist
executor/apps/menubar/README.md+42−0
@@ -0,0 +1,42 @@
1# Executor macOS Menubar (Spike)
2
3This app is an initial Electron-based menubar/tray client for reviewing and resolving pending Executor approvals.
4
5It 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
15Set 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
23If `EXECUTOR_AUTH_TOKEN` is not set, the app bootstraps an anonymous session automatically.
24
25## Run
26
27```bash
28bun 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
391. Replace polling with Convex live subscriptions or an event stream.
402. Add in-app auth flow (WorkOS OAuth/device flow) and secure local token storage.
413. Add macOS packaging (`.app`), code signing, and notarization automation.
424. 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 @@
1import { app, Menu, Notification, Tray, nativeImage, type MenuItemConstructorOptions } from "electron";
2import { ConvexHttpClient } from "convex/browser";
3import { api } from "@executor/convex/_generated/api";
4import type { Id } from "@executor/convex/_generated/dataModel";
5
6type ApprovalDecision = "approved" | "denied";
7
8type PendingApprovalRecord = {
9 id: string;
10 taskId: string;
11 toolPath: string;
12 createdAt: number;
13};
14
15type WorkspaceListItem = {
16 id: Id<"workspaces">;
17 name: string;
18};
19
20type AnonymousContext = {
21 sessionId: string;
22 workspaceId: Id<"workspaces">;
23};
24
25type RuntimeConfig = {
26 convexUrl: string;
27 workspaceId?: Id<"workspaces">;
28 sessionId?: string;
29 authToken?: string;
30 pollIntervalMs: number;
31};
32
33type RuntimeContext = {
34 workspaceId: Id<"workspaces">;
35 sessionId?: string;
36};
37
38const DEFAULT_POLL_INTERVAL_MS = 5_000;
39
40const DEFAULT_ICON_DATA_URL =
41 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/6ioAAAAASUVORK5CYII=";
42
43function 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
51function 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
59function 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
70function 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
85function 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
95function 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
120function isMacOS(): boolean {
121 return process.platform === "darwin";
122}
123
124class 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
391let approvalApp: ApprovalMenubarApp | null = null;
392
393app.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
407app.on("window-all-closed", () => {});
408
409app.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
369// ── Inline tool detail (shown when tool row is expanded) ──369// ── Inline tool detail (shown when tool row is expanded) ──
370 370
371function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {371function 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) = 76372 // Align details under the expand chevron.
373 const insetLeft = depth * 20 + 8 + 76;373 const insetLeft = depth * 20 + 8 + 16 + 8;
374 return (374 return (
375 <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}>375 <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}>
376 {/* Description rendered as markdown/HTML via Streamdown */}376 {/* Description rendered as markdown/HTML via Streamdown */}
executor/convex/auth.config.ts+10−1
@@ -1,4 +1,13 @@
1const clientId = process.env.WORKOS_CLIENT_ID;1function 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
10const clientId = readEnvVar("WORKOS_CLIENT_ID");
2 11
3const authConfig = clientId12const authConfig = clientId
4 ? {13 ? {
executor/lib/managed_runtime.ts+7−11
@@ -412,12 +412,18 @@ async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<str · 411 unmodified lines
412}412}
413 413
414async function findProjectDir(): Promise<string | null> {414async function findProjectDir(): Promise<string | null> {
415 const candidates = [415 const roots = [
416 Bun.env.EXECUTOR_PROJECT_DIR,416 Bun.env.EXECUTOR_PROJECT_DIR,
417 process.cwd(),417 process.cwd(),
418 path.resolve(import.meta.dir, ".."),418 path.resolve(import.meta.dir, ".."),
419 ].filter((value): value is string => Boolean(value && value.trim().length > 0));419 ].filter((value): value is string => Boolean(value && value.trim().length > 0));
420 420
421 const candidates: string[] = [];
422 for (const root of roots) {
423 candidates.push(root);
424 candidates.push(path.join(root, "executor"));
425 }
426
421 for (const candidate of candidates) {427 for (const candidate of candidates) {
422 const convexDir = path.join(candidate, "convex");428 const convexDir = path.join(candidate, "convex");
423 const convexConfig = path.join(convexDir, "convex.config.ts");429 const convexConfig = path.join(convexDir, "convex.config.ts");
@@ -537,16 +543,6 @@ async function ensureWebBundle(info: ManagedRuntimeInfo): Promise<void> { · 113 unmodified lines
537 return;543 return;
538 }544 }
539 545
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
550 const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-web-bundle-"));546 const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-web-bundle-"));
551 const archivePath = path.join(tempDir, info.webArtifactName);547 const archivePath = path.join(tempDir, info.webArtifactName);
552 const localFallbackArchive = path.resolve(import.meta.dir, "..", "dist", "release", info.webArtifactName);548 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
3 3
4INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"4INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
5RUNTIME_DIR="${EXECUTOR_RUNTIME_DIR:-$HOME/.executor/runtime}"5RUNTIME_DIR="${EXECUTOR_RUNTIME_DIR:-$HOME/.executor/runtime}"
6LEGACY_WEB_DIR="${EXECUTOR_LEGACY_WEB_DIR:-$HOME/.executor/web}"
7EXECUTOR_HOME="${EXECUTOR_HOME_DIR:-$HOME/.executor}"6EXECUTOR_HOME="${EXECUTOR_HOME_DIR:-$HOME/.executor}"
8ASSUME_YES="0"7ASSUME_YES="0"
9 8
@@ -41,7 +40,6 @@ if [[ "$ASSUME_YES" != "1" ]]; then · 31 unmodified lines
41 echo "This will remove:"40 echo "This will remove:"
42 echo " - $INSTALL_DIR/executor"41 echo " - $INSTALL_DIR/executor"
43 echo " - $RUNTIME_DIR"42 echo " - $RUNTIME_DIR"
44 echo " - $LEGACY_WEB_DIR"
45 echo ""43 echo ""
46 read -r -p "Continue? [y/N] " response44 read -r -p "Continue? [y/N] " response
47 if [[ ! "$response" =~ ^[Yy]$ ]]; then45 if [[ ! "$response" =~ ^[Yy]$ ]]; then
@@ -52,7 +50,6 @@ fi · 4 unmodified lines
52 50
53rm -f "$INSTALL_DIR/executor"51rm -f "$INSTALL_DIR/executor"
54rm -rf "$RUNTIME_DIR"52rm -rf "$RUNTIME_DIR"
55rm -rf "$LEGACY_WEB_DIR"
56 53
57if [[ -d "$INSTALL_DIR" ]]; then54if [[ -d "$INSTALL_DIR" ]]; then
58 rmdir "$INSTALL_DIR" 2>/dev/null || true55 rmdir "$INSTALL_DIR" 2>/dev/null || true