create-claude-code-visualizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/index.js +393 -0
  2. package/package.json +31 -0
  3. package/templates/CLAUDE.md +108 -0
  4. package/templates/app/.env.local.example +14 -0
  5. package/templates/app/ecosystem.config.js +29 -0
  6. package/templates/app/next-env.d.ts +6 -0
  7. package/templates/app/next.config.ts +16 -0
  8. package/templates/app/package-lock.json +4581 -0
  9. package/templates/app/package.json +38 -0
  10. package/templates/app/postcss.config.js +5 -0
  11. package/templates/app/src/app/agents/[slug]/chat/loading.tsx +26 -0
  12. package/templates/app/src/app/agents/[slug]/chat/page.tsx +579 -0
  13. package/templates/app/src/app/agents/[slug]/loading.tsx +19 -0
  14. package/templates/app/src/app/agents/page.tsx +8 -0
  15. package/templates/app/src/app/api/agents/[slug]/capabilities/route.ts +11 -0
  16. package/templates/app/src/app/api/agents/[slug]/route.ts +57 -0
  17. package/templates/app/src/app/api/agents/route.ts +28 -0
  18. package/templates/app/src/app/api/ai/generate-agent/route.ts +87 -0
  19. package/templates/app/src/app/api/ai/improve-claude-md/route.ts +78 -0
  20. package/templates/app/src/app/api/ai/suggestions/route.ts +64 -0
  21. package/templates/app/src/app/api/ai/title/route.ts +88 -0
  22. package/templates/app/src/app/api/auth/role/route.ts +17 -0
  23. package/templates/app/src/app/api/commands/[slug]/route.ts +61 -0
  24. package/templates/app/src/app/api/commands/route.ts +6 -0
  25. package/templates/app/src/app/api/governance/costs/route.ts +117 -0
  26. package/templates/app/src/app/api/governance/sessions/route.ts +335 -0
  27. package/templates/app/src/app/api/notifications/route.ts +62 -0
  28. package/templates/app/src/app/api/preferences/route.ts +44 -0
  29. package/templates/app/src/app/api/runs/[id]/approve/route.ts +38 -0
  30. package/templates/app/src/app/api/runs/[id]/events/route.ts +28 -0
  31. package/templates/app/src/app/api/runs/[id]/metadata/route.ts +30 -0
  32. package/templates/app/src/app/api/runs/[id]/route.ts +21 -0
  33. package/templates/app/src/app/api/runs/[id]/start/route.ts +61 -0
  34. package/templates/app/src/app/api/runs/[id]/stop/route.ts +16 -0
  35. package/templates/app/src/app/api/runs/[id]/stream/route.ts +201 -0
  36. package/templates/app/src/app/api/runs/route.ts +95 -0
  37. package/templates/app/src/app/api/schedules/[id]/route.ts +81 -0
  38. package/templates/app/src/app/api/schedules/route.ts +75 -0
  39. package/templates/app/src/app/api/settings/access-logs/route.ts +33 -0
  40. package/templates/app/src/app/api/settings/claude-md/route.ts +44 -0
  41. package/templates/app/src/app/api/settings/env-keys/route.ts +271 -0
  42. package/templates/app/src/app/api/settings/users/route.ts +108 -0
  43. package/templates/app/src/app/api/skills/[slug]/route.ts +43 -0
  44. package/templates/app/src/app/api/skills/route.ts +6 -0
  45. package/templates/app/src/app/api/tools/route.ts +65 -0
  46. package/templates/app/src/app/api/uploads/cleanup/route.ts +29 -0
  47. package/templates/app/src/app/api/uploads/route.ts +77 -0
  48. package/templates/app/src/app/auth/callback/route.ts +19 -0
  49. package/templates/app/src/app/globals.css +115 -0
  50. package/templates/app/src/app/layout.tsx +24 -0
  51. package/templates/app/src/app/loading.tsx +16 -0
  52. package/templates/app/src/app/login/page.tsx +64 -0
  53. package/templates/app/src/app/not-authorized/page.tsx +33 -0
  54. package/templates/app/src/app/runs/page.tsx +55 -0
  55. package/templates/app/src/app/schedules/page.tsx +110 -0
  56. package/templates/app/src/app/settings/page.tsx +1294 -0
  57. package/templates/app/src/app/skills/page.tsx +7 -0
  58. package/templates/app/src/components/agent-card.tsx +58 -0
  59. package/templates/app/src/components/agent-grid.tsx +90 -0
  60. package/templates/app/src/components/auth/auth-context.tsx +79 -0
  61. package/templates/app/src/components/chat-thread.tsx +50 -0
  62. package/templates/app/src/components/chat-view.tsx +670 -0
  63. package/templates/app/src/components/commands-browser.tsx +349 -0
  64. package/templates/app/src/components/create-agent-modal.tsx +388 -0
  65. package/templates/app/src/components/governance-dashboard.tsx +397 -0
  66. package/templates/app/src/components/icons.tsx +401 -0
  67. package/templates/app/src/components/layout/agent-sidebar.tsx +504 -0
  68. package/templates/app/src/components/layout/app-shell.tsx +29 -0
  69. package/templates/app/src/components/layout/nav.tsx +87 -0
  70. package/templates/app/src/components/layout/overview-inner.tsx +14 -0
  71. package/templates/app/src/components/layout/profile-menu.tsx +95 -0
  72. package/templates/app/src/components/layout/sidebar.tsx +30 -0
  73. package/templates/app/src/components/markdown.tsx +57 -0
  74. package/templates/app/src/components/message-bar.tsx +161 -0
  75. package/templates/app/src/components/notifications/notification-bell.tsx +104 -0
  76. package/templates/app/src/components/notifications/notification-panel.tsx +116 -0
  77. package/templates/app/src/components/overview/overview-content.tsx +287 -0
  78. package/templates/app/src/components/overview/overview-context.tsx +88 -0
  79. package/templates/app/src/components/preferences-modal.tsx +112 -0
  80. package/templates/app/src/components/run-form.tsx +73 -0
  81. package/templates/app/src/components/run-history-table.tsx +226 -0
  82. package/templates/app/src/components/run-output.tsx +187 -0
  83. package/templates/app/src/components/schedule-form.tsx +148 -0
  84. package/templates/app/src/components/skills-browser.tsx +338 -0
  85. package/templates/app/src/components/tool-tooltip.tsx +82 -0
  86. package/templates/app/src/hooks/use-sse.ts +115 -0
  87. package/templates/app/src/instrumentation.ts +9 -0
  88. package/templates/app/src/lib/agent-cache.ts +19 -0
  89. package/templates/app/src/lib/agent-runner.ts +411 -0
  90. package/templates/app/src/lib/agents.ts +168 -0
  91. package/templates/app/src/lib/ai.ts +40 -0
  92. package/templates/app/src/lib/approval-store.ts +70 -0
  93. package/templates/app/src/lib/auth-guard.ts +116 -0
  94. package/templates/app/src/lib/capabilities.ts +191 -0
  95. package/templates/app/src/lib/line-diff.ts +96 -0
  96. package/templates/app/src/lib/queue.ts +22 -0
  97. package/templates/app/src/lib/redis.ts +12 -0
  98. package/templates/app/src/lib/role-permissions.ts +166 -0
  99. package/templates/app/src/lib/run-agent.ts +442 -0
  100. package/templates/app/src/lib/supabase-browser.ts +8 -0
  101. package/templates/app/src/lib/supabase-middleware.ts +63 -0
  102. package/templates/app/src/lib/supabase-server.ts +28 -0
  103. package/templates/app/src/lib/supabase.ts +6 -0
  104. package/templates/app/src/lib/tool-descriptions.ts +29 -0
  105. package/templates/app/src/lib/types.ts +73 -0
  106. package/templates/app/src/lib/typewriter-animation.ts +159 -0
  107. package/templates/app/src/middleware.ts +13 -0
  108. package/templates/app/tsconfig.json +21 -0
  109. package/templates/app/uploads/.gitkeep +0 -0
  110. package/templates/app/worker/index.ts +342 -0
  111. package/templates/claude/agents/ai-trends-scout.md +66 -0
  112. package/templates/claude/commands/add-to-todos.md +56 -0
  113. package/templates/claude/commands/check-todos.md +56 -0
  114. package/templates/claude/hooks/auto-approve-safe.sh +34 -0
  115. package/templates/claude/hooks/auto-format.sh +25 -0
  116. package/templates/claude/hooks/block-destructive.sh +32 -0
  117. package/templates/claude/hooks/compaction-preserver.sh +16 -0
  118. package/templates/claude/hooks/notify.sh +26 -0
  119. package/templates/claude/settings.local.json +66 -0
  120. package/templates/claude/skills/frontend-design/SKILL.md +127 -0
  121. package/templates/claude/skills/frontend-design/reference/color-and-contrast.md +132 -0
  122. package/templates/claude/skills/frontend-design/reference/interaction-design.md +123 -0
  123. package/templates/claude/skills/frontend-design/reference/motion-design.md +99 -0
  124. package/templates/claude/skills/frontend-design/reference/responsive-design.md +114 -0
  125. package/templates/claude/skills/frontend-design/reference/spatial-design.md +100 -0
  126. package/templates/claude/skills/frontend-design/reference/typography.md +131 -0
  127. package/templates/claude/skills/frontend-design/reference/ux-writing.md +107 -0
  128. package/templates/claude/skills/gws-admin-reports/SKILL.md +57 -0
  129. package/templates/claude/skills/gws-calendar/SKILL.md +108 -0
  130. package/templates/claude/skills/gws-calendar-agenda/SKILL.md +52 -0
  131. package/templates/claude/skills/gws-calendar-insert/SKILL.md +55 -0
  132. package/templates/claude/skills/gws-chat/SKILL.md +73 -0
  133. package/templates/claude/skills/gws-chat-send/SKILL.md +49 -0
  134. package/templates/claude/skills/gws-classroom/SKILL.md +75 -0
  135. package/templates/claude/skills/gws-docs/SKILL.md +48 -0
  136. package/templates/claude/skills/gws-docs-write/SKILL.md +49 -0
  137. package/templates/claude/skills/gws-drive/SKILL.md +137 -0
  138. package/templates/claude/skills/gws-drive-upload/SKILL.md +52 -0
  139. package/templates/claude/skills/gws-events/SKILL.md +67 -0
  140. package/templates/claude/skills/gws-events-renew/SKILL.md +48 -0
  141. package/templates/claude/skills/gws-events-subscribe/SKILL.md +59 -0
  142. package/templates/claude/skills/gws-forms/SKILL.md +45 -0
  143. package/templates/claude/skills/gws-gmail/SKILL.md +59 -0
  144. package/templates/claude/skills/gws-gmail-forward/SKILL.md +53 -0
  145. package/templates/claude/skills/gws-gmail-reply/SKILL.md +56 -0
  146. package/templates/claude/skills/gws-gmail-reply-all/SKILL.md +60 -0
  147. package/templates/claude/skills/gws-gmail-send/SKILL.md +55 -0
  148. package/templates/claude/skills/gws-gmail-triage/SKILL.md +50 -0
  149. package/templates/claude/skills/gws-gmail-watch/SKILL.md +58 -0
  150. package/templates/claude/skills/gws-keep/SKILL.md +48 -0
  151. package/templates/claude/skills/gws-meet/SKILL.md +51 -0
  152. package/templates/claude/skills/gws-modelarmor/SKILL.md +42 -0
  153. package/templates/claude/skills/gws-modelarmor-create-template/SKILL.md +53 -0
  154. package/templates/claude/skills/gws-modelarmor-sanitize-prompt/SKILL.md +48 -0
  155. package/templates/claude/skills/gws-modelarmor-sanitize-response/SKILL.md +48 -0
  156. package/templates/claude/skills/gws-people/SKILL.md +67 -0
  157. package/templates/claude/skills/gws-shared/SKILL.md +66 -0
  158. package/templates/claude/skills/gws-sheets/SKILL.md +53 -0
  159. package/templates/claude/skills/gws-sheets-append/SKILL.md +51 -0
  160. package/templates/claude/skills/gws-sheets-read/SKILL.md +47 -0
  161. package/templates/claude/skills/gws-slides/SKILL.md +43 -0
  162. package/templates/claude/skills/gws-tasks/SKILL.md +56 -0
  163. package/templates/claude/skills/gws-workflow/SKILL.md +44 -0
  164. package/templates/claude/skills/gws-workflow-email-to-task/SKILL.md +47 -0
  165. package/templates/claude/skills/gws-workflow-file-announce/SKILL.md +50 -0
  166. package/templates/claude/skills/gws-workflow-meeting-prep/SKILL.md +47 -0
  167. package/templates/claude/skills/gws-workflow-standup-report/SKILL.md +46 -0
  168. package/templates/claude/skills/gws-workflow-weekly-digest/SKILL.md +46 -0
  169. package/templates/claude/skills/persona-content-creator/SKILL.md +33 -0
  170. package/templates/claude/skills/persona-customer-support/SKILL.md +34 -0
  171. package/templates/claude/skills/persona-event-coordinator/SKILL.md +35 -0
  172. package/templates/claude/skills/persona-exec-assistant/SKILL.md +35 -0
  173. package/templates/claude/skills/persona-hr-coordinator/SKILL.md +33 -0
  174. package/templates/claude/skills/persona-it-admin/SKILL.md +30 -0
  175. package/templates/claude/skills/persona-project-manager/SKILL.md +35 -0
  176. package/templates/claude/skills/persona-researcher/SKILL.md +33 -0
  177. package/templates/claude/skills/persona-sales-ops/SKILL.md +35 -0
  178. package/templates/claude/skills/persona-team-lead/SKILL.md +36 -0
  179. package/templates/claude/skills/recipe-backup-sheet-as-csv/SKILL.md +25 -0
  180. package/templates/claude/skills/recipe-batch-invite-to-event/SKILL.md +25 -0
  181. package/templates/claude/skills/recipe-block-focus-time/SKILL.md +24 -0
  182. package/templates/claude/skills/recipe-bulk-download-folder/SKILL.md +25 -0
  183. package/templates/claude/skills/recipe-collect-form-responses/SKILL.md +25 -0
  184. package/templates/claude/skills/recipe-compare-sheet-tabs/SKILL.md +25 -0
  185. package/templates/claude/skills/recipe-copy-sheet-for-new-month/SKILL.md +25 -0
  186. package/templates/claude/skills/recipe-create-classroom-course/SKILL.md +25 -0
  187. package/templates/claude/skills/recipe-create-doc-from-template/SKILL.md +29 -0
  188. package/templates/claude/skills/recipe-create-events-from-sheet/SKILL.md +24 -0
  189. package/templates/claude/skills/recipe-create-expense-tracker/SKILL.md +26 -0
  190. package/templates/claude/skills/recipe-create-feedback-form/SKILL.md +25 -0
  191. package/templates/claude/skills/recipe-create-gmail-filter/SKILL.md +26 -0
  192. package/templates/claude/skills/recipe-create-meet-space/SKILL.md +25 -0
  193. package/templates/claude/skills/recipe-create-presentation/SKILL.md +25 -0
  194. package/templates/claude/skills/recipe-create-shared-drive/SKILL.md +25 -0
  195. package/templates/claude/skills/recipe-create-task-list/SKILL.md +26 -0
  196. package/templates/claude/skills/recipe-create-vacation-responder/SKILL.md +25 -0
  197. package/templates/claude/skills/recipe-draft-email-from-doc/SKILL.md +25 -0
  198. package/templates/claude/skills/recipe-email-drive-link/SKILL.md +25 -0
  199. package/templates/claude/skills/recipe-find-free-time/SKILL.md +25 -0
  200. package/templates/claude/skills/recipe-find-large-files/SKILL.md +24 -0
  201. package/templates/claude/skills/recipe-forward-labeled-emails/SKILL.md +27 -0
  202. package/templates/claude/skills/recipe-generate-report-from-sheet/SKILL.md +34 -0
  203. package/templates/claude/skills/recipe-label-and-archive-emails/SKILL.md +25 -0
  204. package/templates/claude/skills/recipe-log-deal-update/SKILL.md +25 -0
  205. package/templates/claude/skills/recipe-organize-drive-folder/SKILL.md +26 -0
  206. package/templates/claude/skills/recipe-plan-weekly-schedule/SKILL.md +26 -0
  207. package/templates/claude/skills/recipe-post-mortem-setup/SKILL.md +25 -0
  208. package/templates/claude/skills/recipe-reschedule-meeting/SKILL.md +25 -0
  209. package/templates/claude/skills/recipe-review-meet-participants/SKILL.md +25 -0
  210. package/templates/claude/skills/recipe-review-overdue-tasks/SKILL.md +25 -0
  211. package/templates/claude/skills/recipe-save-email-attachments/SKILL.md +26 -0
  212. package/templates/claude/skills/recipe-save-email-to-doc/SKILL.md +29 -0
  213. package/templates/claude/skills/recipe-schedule-recurring-event/SKILL.md +24 -0
  214. package/templates/claude/skills/recipe-send-team-announcement/SKILL.md +24 -0
  215. package/templates/claude/skills/recipe-share-doc-and-notify/SKILL.md +25 -0
  216. package/templates/claude/skills/recipe-share-event-materials/SKILL.md +25 -0
  217. package/templates/claude/skills/recipe-share-folder-with-team/SKILL.md +26 -0
  218. package/templates/claude/skills/recipe-sync-contacts-to-sheet/SKILL.md +25 -0
  219. package/templates/claude/skills/recipe-watch-drive-changes/SKILL.md +25 -0
  220. package/templates/mcp.json +12 -0
@@ -0,0 +1,411 @@
1
+ // Agent Runner — manages agent execution independently of HTTP connections.
2
+ // Agents run in-memory; SSE streams are read-only observers.
3
+
4
+ import { EventEmitter } from "events";
5
+ import { supabase } from "@/lib/supabase";
6
+ import { executeAgent } from "@/lib/run-agent";
7
+ import type { Role } from "@/lib/auth-guard";
8
+ import type { AgentMeta } from "@/lib/types";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // RunContext — one per active agent run
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface RunContext {
15
+ emitter: EventEmitter;
16
+ status: "running" | "done";
17
+ pendingApprovals: Map<string, { resolve: (approved: boolean) => void; toolName: string; toolInput: Record<string, unknown> }>;
18
+ abortController: AbortController;
19
+ bufferedText: string;
20
+ flushTimer: ReturnType<typeof setInterval> | null;
21
+ seq: number;
22
+ subscriberCount: number; // how many SSE clients are watching
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Global registry (survives Next.js dev-mode module reloads)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const REGISTRY_KEY = "__agent_runner_registry__" as const;
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ const g = globalThis as any;
32
+ if (!g[REGISTRY_KEY]) {
33
+ g[REGISTRY_KEY] = new Map<string, RunContext>();
34
+ }
35
+ export const RunnerRegistry: Map<string, RunContext> = g[REGISTRY_KEY];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ async function writeEvent(runId: string, seq: number, eventType: string, payload: Record<string, unknown>) {
42
+ await supabase.from("run_events").insert({ run_id: runId, seq, event_type: eventType, payload });
43
+ }
44
+
45
+ function emit(ctx: RunContext, runId: string, eventType: string, payload: Record<string, unknown>) {
46
+ ctx.emitter.emit("event", { type: eventType, seq: ctx.seq, ...payload });
47
+ }
48
+
49
+ function flushText(ctx: RunContext, runId: string): number | null {
50
+ if (!ctx.bufferedText) return null;
51
+ const seq = ++ctx.seq;
52
+ const text = ctx.bufferedText;
53
+ ctx.bufferedText = "";
54
+ // Write batched text to DB (fire-and-forget)
55
+ writeEvent(runId, seq, "text_chunk", { text });
56
+ return seq;
57
+ }
58
+
59
+ function emitAndWrite(ctx: RunContext, runId: string, eventType: string, payload: Record<string, unknown>) {
60
+ // Flush any buffered text first
61
+ flushText(ctx, runId);
62
+ const seq = ++ctx.seq;
63
+ emit(ctx, runId, eventType, { ...payload, seq });
64
+ writeEvent(runId, seq, eventType, payload);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // executeDetached — starts agent, returns immediately
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function executeDetached(
72
+ runId: string,
73
+ agent: AgentMeta,
74
+ prompt: string,
75
+ sessionId?: string,
76
+ files?: string[],
77
+ userRole?: Role,
78
+ userEmail?: string
79
+ ): void {
80
+ const ctx: RunContext = {
81
+ emitter: new EventEmitter(),
82
+ status: "running",
83
+ pendingApprovals: new Map(),
84
+ abortController: new AbortController(),
85
+ bufferedText: "",
86
+ flushTimer: null,
87
+ seq: 0,
88
+ subscriberCount: 0,
89
+ };
90
+
91
+ // Track whether an error event was already emitted (via onError callback)
92
+ // to prevent the catch block from emitting a duplicate
93
+ let errorAlreadyEmitted = false;
94
+
95
+ // Allow many SSE listeners
96
+ ctx.emitter.setMaxListeners(50);
97
+
98
+ RunnerRegistry.set(runId, ctx);
99
+
100
+ // Start 3-second text flush interval
101
+ ctx.flushTimer = setInterval(() => {
102
+ flushText(ctx, runId);
103
+ }, 3000);
104
+
105
+ // Inject file paths into prompt
106
+ let agentPrompt = prompt;
107
+ if (files && files.length > 0) {
108
+ const fileList = files.map((p) => ` - ${p}`).join("\n");
109
+ agentPrompt = `${prompt}\n\n[Attached files — use the Read tool to read these]\n${fileList}`;
110
+ }
111
+
112
+ // Mark as running
113
+ supabase
114
+ .from("agent_runs")
115
+ .update({ status: "running", started_at: new Date().toISOString() })
116
+ .eq("id", runId)
117
+ .then(() => {
118
+ emitAndWrite(ctx, runId, "status", { status: "running" });
119
+ });
120
+
121
+ // Fire-and-forget execution
122
+ (async () => {
123
+ try {
124
+ const result = await executeAgent(agent, agentPrompt, {
125
+ signal: ctx.abortController.signal,
126
+ onToken: (text) => {
127
+ // Emit to live listeners immediately (every token).
128
+ // No seq — tokens are transient live events, not deduplicated.
129
+ // They get batched into text_chunk DB events (which have seq) by the flush timer.
130
+ ctx.bufferedText += text;
131
+ ctx.emitter.emit("event", { type: "token", text });
132
+ },
133
+ onToolCall: (name, status, detail) => {
134
+ emitAndWrite(ctx, runId, "tool_call", {
135
+ name,
136
+ status,
137
+ ...(detail?.input != null ? { input: detail.input } : {}),
138
+ ...(detail?.result != null ? { result: detail.result } : {}),
139
+ });
140
+ },
141
+ onThinking: (active) => {
142
+ emitAndWrite(ctx, runId, "thinking", { active });
143
+ },
144
+ onProgress: (info) => {
145
+ emitAndWrite(ctx, runId, "progress", { info });
146
+ },
147
+ onError: (err) => {
148
+ errorAlreadyEmitted = true;
149
+ emitAndWrite(ctx, runId, "error", { error: err });
150
+ },
151
+ onDebug: (msg) => {
152
+ // Debug only goes to live listeners, not DB
153
+ ctx.emitter.emit("event", { type: "debug", message: msg });
154
+ },
155
+ onSessionInit: (sid) => {
156
+ emitAndWrite(ctx, runId, "session_init", { session_id: sid });
157
+ // Store SDK session ID in metadata (not session_id column — that's the conversation grouping ID)
158
+ supabase
159
+ .from("agent_runs")
160
+ .select("metadata")
161
+ .eq("id", runId)
162
+ .single()
163
+ .then(({ data }) => {
164
+ const meta = (data?.metadata as Record<string, unknown>) || {};
165
+ supabase
166
+ .from("agent_runs")
167
+ .update({ metadata: { ...meta, sdk_session_id: sid } })
168
+ .eq("id", runId)
169
+ .then(() => {});
170
+ });
171
+ },
172
+ onApprovalRequired: async (toolName, toolInput, toolUseId) => {
173
+ // Flush text, then emit approval event
174
+ flushText(ctx, runId);
175
+ const seq = ++ctx.seq;
176
+
177
+ const approvalPayload = {
178
+ tool_name: toolName,
179
+ tool_input: toolInput,
180
+ tool_use_id: toolUseId,
181
+ };
182
+
183
+ emit(ctx, runId, "approval_required", { ...approvalPayload, seq });
184
+ writeEvent(runId, seq, "approval_required", approvalPayload);
185
+
186
+ // Persist to agent_runs so it survives reconnection
187
+ await supabase
188
+ .from("agent_runs")
189
+ .update({ pending_approval: approvalPayload })
190
+ .eq("id", runId);
191
+
192
+ // Create notification if no SSE clients are watching
193
+ if (ctx.subscriberCount === 0) {
194
+ const { data: run } = await supabase
195
+ .from("agent_runs")
196
+ .select("agent_slug, agent_name, session_id")
197
+ .eq("id", runId)
198
+ .single();
199
+
200
+ if (run) {
201
+ await supabase.from("notifications").insert({
202
+ run_id: runId,
203
+ agent_slug: run.agent_slug,
204
+ session_id: run.session_id,
205
+ type: "approval_needed",
206
+ title: `Approval needed: ${toolName}`,
207
+ summary: `${run.agent_name} needs permission to use ${toolName}`,
208
+ });
209
+ }
210
+ }
211
+
212
+ // Wait for approval (Promise blocks agent execution)
213
+ return new Promise<boolean>((resolve) => {
214
+ ctx.pendingApprovals.set(toolUseId, { resolve, toolName, toolInput });
215
+ });
216
+ },
217
+ }, sessionId, userRole, userEmail);
218
+
219
+ // --- Completion ---
220
+ flushText(ctx, runId);
221
+ if (ctx.flushTimer) clearInterval(ctx.flushTimer);
222
+
223
+ const finalStatus = result.aborted ? "stopped" : "completed";
224
+ const finalPayload = {
225
+ output: result.output,
226
+ cost_usd: result.costUsd,
227
+ duration_ms: result.durationMs,
228
+ session_id: result.sessionId,
229
+ };
230
+
231
+ await supabase
232
+ .from("agent_runs")
233
+ .update({
234
+ status: finalStatus,
235
+ output: result.output || null,
236
+ cost_usd: result.costUsd,
237
+ duration_ms: result.durationMs,
238
+ pending_approval: null,
239
+ completed_at: new Date().toISOString(),
240
+ })
241
+ .eq("id", runId);
242
+
243
+ const doneType = result.aborted ? "stopped" : "done";
244
+ emitAndWrite(ctx, runId, doneType, finalPayload);
245
+
246
+ // Create completion notification
247
+ const { data: run } = await supabase
248
+ .from("agent_runs")
249
+ .select("agent_slug, agent_name, session_id")
250
+ .eq("id", runId)
251
+ .single();
252
+
253
+ if (run) {
254
+ await supabase.from("notifications").insert({
255
+ run_id: runId,
256
+ agent_slug: run.agent_slug,
257
+ session_id: run.session_id,
258
+ type: "completed",
259
+ title: `${run.agent_name}: Completed`,
260
+ summary: result.output ? result.output.slice(0, 200) : null,
261
+ });
262
+ }
263
+
264
+ ctx.status = "done";
265
+ } catch (err) {
266
+ const errorMsg = err instanceof Error ? err.message : String(err);
267
+
268
+ flushText(ctx, runId);
269
+ if (ctx.flushTimer) clearInterval(ctx.flushTimer);
270
+
271
+ await supabase
272
+ .from("agent_runs")
273
+ .update({
274
+ status: "failed",
275
+ error: errorMsg,
276
+ pending_approval: null,
277
+ completed_at: new Date().toISOString(),
278
+ })
279
+ .eq("id", runId);
280
+
281
+ // Only emit error if onError callback didn't already emit it
282
+ // (prevents duplicate "balance too low" / credit error messages)
283
+ if (!errorAlreadyEmitted) {
284
+ emitAndWrite(ctx, runId, "error", { error: errorMsg });
285
+ }
286
+
287
+ // Create failure notification
288
+ const { data: run } = await supabase
289
+ .from("agent_runs")
290
+ .select("agent_slug, agent_name, session_id")
291
+ .eq("id", runId)
292
+ .single();
293
+
294
+ if (run) {
295
+ await supabase.from("notifications").insert({
296
+ run_id: runId,
297
+ agent_slug: run.agent_slug,
298
+ session_id: run.session_id,
299
+ type: "failed",
300
+ title: `${run.agent_name}: Failed`,
301
+ summary: errorMsg.slice(0, 200),
302
+ });
303
+ }
304
+
305
+ ctx.status = "done";
306
+ } finally {
307
+ // Deny any still-pending approvals
308
+ for (const [, entry] of ctx.pendingApprovals) {
309
+ entry.resolve(false);
310
+ }
311
+ ctx.pendingApprovals.clear();
312
+
313
+ // Clean up after a delay to let final SSE events drain
314
+ setTimeout(() => {
315
+ RunnerRegistry.delete(runId);
316
+ }, 30_000);
317
+ }
318
+ })();
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // getRunContext — check if agent is running in this process
323
+ // ---------------------------------------------------------------------------
324
+
325
+ export function getRunContext(runId: string): RunContext | undefined {
326
+ return RunnerRegistry.get(runId);
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // stopRun — abort a running agent
331
+ // ---------------------------------------------------------------------------
332
+
333
+ export async function stopRun(runId: string): Promise<boolean> {
334
+ const ctx = RunnerRegistry.get(runId);
335
+ if (ctx) {
336
+ ctx.abortController.abort();
337
+ return true;
338
+ }
339
+ // RunContext not found — update DB directly (server may have restarted)
340
+ const { error } = await supabase
341
+ .from("agent_runs")
342
+ .update({ status: "stopped", completed_at: new Date().toISOString(), pending_approval: null })
343
+ .eq("id", runId)
344
+ .eq("status", "running");
345
+ return !error;
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // submitApproval — resolve a pending approval in RunContext
350
+ // ---------------------------------------------------------------------------
351
+
352
+ export function submitApproval(runId: string, toolUseId: string, approved: boolean): boolean {
353
+ const ctx = RunnerRegistry.get(runId);
354
+ if (!ctx) return false;
355
+ const entry = ctx.pendingApprovals.get(toolUseId);
356
+ if (!entry) return false;
357
+ entry.resolve(approved);
358
+ ctx.pendingApprovals.delete(toolUseId);
359
+
360
+ // Clear pending_approval from DB and write resolution event
361
+ supabase
362
+ .from("agent_runs")
363
+ .update({ pending_approval: null })
364
+ .eq("id", runId)
365
+ .then(() => {});
366
+ emitAndWrite(ctx, runId, "approval_resolved", { tool_use_id: toolUseId, approved });
367
+
368
+ return true;
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // cleanupStaleRuns — mark old "running" runs as failed on startup
373
+ // ---------------------------------------------------------------------------
374
+
375
+ export async function cleanupStaleRuns(): Promise<void> {
376
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
377
+
378
+ const { data: staleRuns } = await supabase
379
+ .from("agent_runs")
380
+ .select("id, agent_slug, agent_name, session_id")
381
+ .eq("status", "running")
382
+ .lt("started_at", tenMinutesAgo);
383
+
384
+ if (!staleRuns || staleRuns.length === 0) return;
385
+
386
+ for (const run of staleRuns) {
387
+ // Skip runs that are actually running in this process
388
+ if (RunnerRegistry.has(run.id)) continue;
389
+
390
+ await supabase
391
+ .from("agent_runs")
392
+ .update({
393
+ status: "failed",
394
+ error: "Server restarted during execution",
395
+ pending_approval: null,
396
+ completed_at: new Date().toISOString(),
397
+ })
398
+ .eq("id", run.id);
399
+
400
+ await supabase.from("notifications").insert({
401
+ run_id: run.id,
402
+ agent_slug: run.agent_slug,
403
+ session_id: run.session_id,
404
+ type: "failed",
405
+ title: `${run.agent_name}: Failed (server restart)`,
406
+ summary: "Server restarted during execution. Please retry.",
407
+ });
408
+ }
409
+
410
+ console.log(`[agent-runner] Cleaned up ${staleRuns.length} stale runs`);
411
+ }
@@ -0,0 +1,168 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import matter from "gray-matter";
4
+ import type { AgentMeta } from "./types";
5
+
6
+ const PROJECT_ROOT = process.env.PROJECT_ROOT || path.resolve(__dirname, "../../../..");
7
+ const AGENTS_DIR = path.join(PROJECT_ROOT, ".claude", "agents");
8
+
9
+ // The main assistant — uses CLAUDE.md as its prompt, all other agents as subagents
10
+ const MAIN_AGENT: AgentMeta = {
11
+ slug: "main",
12
+ name: "My Assistant",
13
+ description: "Your personal AI assistant with full access to all tools, MCP servers, and specialist subagents.",
14
+ tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebSearch", "WebFetch", "Agent", "Mcp", "Task", "NotebookEdit", "AskUserQuestion"],
15
+ color: "#8B5CF6",
16
+ emoji: "⚡",
17
+ vibe: "Handles everything or knows who can.",
18
+ filePath: "",
19
+ };
20
+
21
+ let cache: { agents: AgentMeta[]; timestamp: number } | null = null;
22
+ const CACHE_TTL = 30_000; // 30 seconds
23
+
24
+ export function getAgents(): AgentMeta[] {
25
+ if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
26
+ return cache.agents;
27
+ }
28
+
29
+ const files = fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".md"));
30
+
31
+ const agents: AgentMeta[] = files.map((file) => {
32
+ const filePath = path.join(AGENTS_DIR, file);
33
+ const raw = fs.readFileSync(filePath, "utf-8");
34
+ const { data } = matter(raw);
35
+
36
+ const slug = path.basename(file, ".md");
37
+ const tools = data.tools
38
+ ? String(data.tools)
39
+ .split(",")
40
+ .map((t: string) => t.trim())
41
+ .filter(Boolean)
42
+ : [];
43
+
44
+ const mcpServers = data.mcpServers
45
+ ? String(data.mcpServers)
46
+ .split(",")
47
+ .map((s: string) => s.trim())
48
+ .filter(Boolean)
49
+ : undefined;
50
+
51
+ return {
52
+ slug,
53
+ name: data.name || slug,
54
+ description: data.description || "",
55
+ tools,
56
+ mcpServers,
57
+ color: data.color,
58
+ emoji: data.emoji,
59
+ vibe: data.vibe,
60
+ model: data.model,
61
+ filePath,
62
+ };
63
+ });
64
+
65
+ // Main assistant always comes first
66
+ const all = [MAIN_AGENT, ...agents];
67
+ cache = { agents: all, timestamp: Date.now() };
68
+ return all;
69
+ }
70
+
71
+ export function getAgent(slug: string): AgentMeta | undefined {
72
+ return getAgents().find((a) => a.slug === slug);
73
+ }
74
+
75
+ export function clearAgentCache(): void {
76
+ cache = null;
77
+ }
78
+
79
+ export function updateAgentMeta(
80
+ slug: string,
81
+ updates: Partial<Pick<AgentMeta, "name" | "description" | "emoji" | "vibe" | "tools" | "mcpServers">>
82
+ ): AgentMeta | null {
83
+ if (slug === "main") return null; // Main agent is not file-backed
84
+
85
+ const filePath = path.join(AGENTS_DIR, `${slug}.md`);
86
+ if (!fs.existsSync(filePath)) return null;
87
+
88
+ const raw = fs.readFileSync(filePath, "utf-8");
89
+ const { data, content } = matter(raw);
90
+
91
+ if (updates.name !== undefined) data.name = updates.name;
92
+ if (updates.description !== undefined) data.description = updates.description;
93
+ if (updates.emoji !== undefined) data.emoji = updates.emoji;
94
+ if (updates.vibe !== undefined) data.vibe = updates.vibe;
95
+ if (updates.tools !== undefined) data.tools = updates.tools.join(", ");
96
+ if (updates.mcpServers !== undefined) {
97
+ if (updates.mcpServers.length > 0) {
98
+ data.mcpServers = updates.mcpServers.join(", ");
99
+ } else {
100
+ delete data.mcpServers;
101
+ }
102
+ }
103
+
104
+ const updated = matter.stringify(content, data);
105
+ fs.writeFileSync(filePath, updated, "utf-8");
106
+
107
+ // Bust cache so next read picks up changes
108
+ cache = null;
109
+
110
+ return getAgent(slug) || null;
111
+ }
112
+
113
+ export function getAgentContent(slug: string): string {
114
+ if (slug === "main") return ""; // Main agent uses CLAUDE.md directly
115
+ const filePath = path.join(AGENTS_DIR, `${slug}.md`);
116
+ const raw = fs.readFileSync(filePath, "utf-8");
117
+ const { content } = matter(raw);
118
+ return content.trim();
119
+ }
120
+
121
+ export function createAgent(meta: {
122
+ name: string;
123
+ description: string;
124
+ tools?: string;
125
+ mcpServers?: string;
126
+ color?: string;
127
+ emoji?: string;
128
+ vibe?: string;
129
+ model?: string;
130
+ content: string;
131
+ }): AgentMeta {
132
+ // Generate slug from name
133
+ const slug = meta.name
134
+ .toLowerCase()
135
+ .replace(/[^a-z0-9]+/g, "-")
136
+ .replace(/^-|-$/g, "");
137
+
138
+ const filePath = path.join(AGENTS_DIR, `${slug}.md`);
139
+ if (fs.existsSync(filePath)) {
140
+ throw new Error(`Agent "${slug}" already exists`);
141
+ }
142
+
143
+ const frontmatter: Record<string, string> = {
144
+ name: meta.name,
145
+ description: meta.description,
146
+ };
147
+ if (meta.tools) frontmatter.tools = meta.tools;
148
+ if (meta.mcpServers) frontmatter.mcpServers = meta.mcpServers;
149
+ if (meta.color) frontmatter.color = meta.color;
150
+ if (meta.emoji) frontmatter.emoji = meta.emoji;
151
+ if (meta.vibe) frontmatter.vibe = meta.vibe;
152
+ if (meta.model) frontmatter.model = meta.model;
153
+
154
+ const fileContent = matter.stringify(meta.content, frontmatter);
155
+ fs.writeFileSync(filePath, fileContent, "utf-8");
156
+
157
+ cache = null;
158
+ return getAgent(slug)!;
159
+ }
160
+
161
+ export function deleteAgent(slug: string): boolean {
162
+ if (slug === "main") return false;
163
+ const filePath = path.join(AGENTS_DIR, `${slug}.md`);
164
+ if (!fs.existsSync(filePath)) return false;
165
+ fs.unlinkSync(filePath);
166
+ cache = null;
167
+ return true;
168
+ }
@@ -0,0 +1,40 @@
1
+ const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
2
+ const MODEL = "claude-haiku-4-5-20251001";
3
+
4
+ interface Message {
5
+ role: "user" | "assistant";
6
+ content: string;
7
+ }
8
+
9
+ export async function callHaiku(
10
+ system: string,
11
+ messages: Message[],
12
+ maxTokens = 256
13
+ ): Promise<string> {
14
+ const apiKey = process.env.ANTHROPIC_API_KEY;
15
+ if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set");
16
+
17
+ const res = await fetch(ANTHROPIC_API_URL, {
18
+ method: "POST",
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ "x-api-key": apiKey,
22
+ "anthropic-version": "2023-06-01",
23
+ },
24
+ body: JSON.stringify({
25
+ model: MODEL,
26
+ max_tokens: maxTokens,
27
+ system,
28
+ messages,
29
+ }),
30
+ });
31
+
32
+ if (!res.ok) {
33
+ const body = await res.text();
34
+ throw new Error(`Anthropic API error ${res.status}: ${body}`);
35
+ }
36
+
37
+ const data = await res.json();
38
+ const block = data.content?.[0];
39
+ return block?.type === "text" ? block.text : "";
40
+ }
@@ -0,0 +1,70 @@
1
+ // Approval store — now delegates to RunnerRegistry for active runs.
2
+ // Keeps the old in-memory map as fallback for any edge cases,
3
+ // and preserves isReadOnlyMcpTool which is used by run-agent.ts.
4
+
5
+ import { submitApproval as runnerSubmit, getRunContext } from "@/lib/agent-runner";
6
+
7
+ // --- Legacy in-memory map (fallback) ---
8
+
9
+ interface PendingApproval {
10
+ resolve: (approved: boolean) => void;
11
+ toolName: string;
12
+ toolInput: Record<string, unknown>;
13
+ }
14
+
15
+ const globalKey = "__approval_pending_map__" as const;
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ const g = globalThis as any;
18
+ if (!g[globalKey]) {
19
+ g[globalKey] = new Map<string, PendingApproval>();
20
+ }
21
+ const pending: Map<string, PendingApproval> = g[globalKey];
22
+
23
+ export function requestApproval(
24
+ runId: string,
25
+ toolUseId: string,
26
+ toolName: string,
27
+ toolInput: Record<string, unknown>
28
+ ): Promise<boolean> {
29
+ return new Promise<boolean>((resolve) => {
30
+ pending.set(`${runId}:${toolUseId}`, { resolve, toolName, toolInput });
31
+ });
32
+ }
33
+
34
+ export function submitApproval(
35
+ runId: string,
36
+ toolUseId: string,
37
+ approved: boolean
38
+ ): boolean {
39
+ // Try RunnerRegistry first (new detached execution path)
40
+ if (getRunContext(runId)) {
41
+ return runnerSubmit(runId, toolUseId, approved);
42
+ }
43
+ // Fallback to legacy map
44
+ const key = `${runId}:${toolUseId}`;
45
+ const entry = pending.get(key);
46
+ if (!entry) return false;
47
+ entry.resolve(approved);
48
+ pending.delete(key);
49
+ return true;
50
+ }
51
+
52
+ export function cancelAllForRun(runId: string): void {
53
+ for (const [key, entry] of pending) {
54
+ if (key.startsWith(`${runId}:`)) {
55
+ entry.resolve(false);
56
+ pending.delete(key);
57
+ }
58
+ }
59
+ }
60
+
61
+ // Check if a tool name looks like a read-only operation
62
+ const READ_VERBS = ["get", "list", "search", "read", "find", "view", "check", "fetch", "show", "describe", "take_screenshot", "take_snapshot", "take_memory_snapshot", "lighthouse_audit", "performance_analyze", "list_console", "list_network", "list_pages", "get_console", "get_network"];
63
+
64
+ export function isReadOnlyMcpTool(toolName: string): boolean {
65
+ const lower = toolName.toLowerCase();
66
+ return READ_VERBS.some((verb) => {
67
+ const pattern = new RegExp(`(?:^|__|_)${verb}(?:_|$)`);
68
+ return pattern.test(lower);
69
+ });
70
+ }