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.
- package/index.js +393 -0
- package/package.json +31 -0
- package/templates/CLAUDE.md +108 -0
- package/templates/app/.env.local.example +14 -0
- package/templates/app/ecosystem.config.js +29 -0
- package/templates/app/next-env.d.ts +6 -0
- package/templates/app/next.config.ts +16 -0
- package/templates/app/package-lock.json +4581 -0
- package/templates/app/package.json +38 -0
- package/templates/app/postcss.config.js +5 -0
- package/templates/app/src/app/agents/[slug]/chat/loading.tsx +26 -0
- package/templates/app/src/app/agents/[slug]/chat/page.tsx +579 -0
- package/templates/app/src/app/agents/[slug]/loading.tsx +19 -0
- package/templates/app/src/app/agents/page.tsx +8 -0
- package/templates/app/src/app/api/agents/[slug]/capabilities/route.ts +11 -0
- package/templates/app/src/app/api/agents/[slug]/route.ts +57 -0
- package/templates/app/src/app/api/agents/route.ts +28 -0
- package/templates/app/src/app/api/ai/generate-agent/route.ts +87 -0
- package/templates/app/src/app/api/ai/improve-claude-md/route.ts +78 -0
- package/templates/app/src/app/api/ai/suggestions/route.ts +64 -0
- package/templates/app/src/app/api/ai/title/route.ts +88 -0
- package/templates/app/src/app/api/auth/role/route.ts +17 -0
- package/templates/app/src/app/api/commands/[slug]/route.ts +61 -0
- package/templates/app/src/app/api/commands/route.ts +6 -0
- package/templates/app/src/app/api/governance/costs/route.ts +117 -0
- package/templates/app/src/app/api/governance/sessions/route.ts +335 -0
- package/templates/app/src/app/api/notifications/route.ts +62 -0
- package/templates/app/src/app/api/preferences/route.ts +44 -0
- package/templates/app/src/app/api/runs/[id]/approve/route.ts +38 -0
- package/templates/app/src/app/api/runs/[id]/events/route.ts +28 -0
- package/templates/app/src/app/api/runs/[id]/metadata/route.ts +30 -0
- package/templates/app/src/app/api/runs/[id]/route.ts +21 -0
- package/templates/app/src/app/api/runs/[id]/start/route.ts +61 -0
- package/templates/app/src/app/api/runs/[id]/stop/route.ts +16 -0
- package/templates/app/src/app/api/runs/[id]/stream/route.ts +201 -0
- package/templates/app/src/app/api/runs/route.ts +95 -0
- package/templates/app/src/app/api/schedules/[id]/route.ts +81 -0
- package/templates/app/src/app/api/schedules/route.ts +75 -0
- package/templates/app/src/app/api/settings/access-logs/route.ts +33 -0
- package/templates/app/src/app/api/settings/claude-md/route.ts +44 -0
- package/templates/app/src/app/api/settings/env-keys/route.ts +271 -0
- package/templates/app/src/app/api/settings/users/route.ts +108 -0
- package/templates/app/src/app/api/skills/[slug]/route.ts +43 -0
- package/templates/app/src/app/api/skills/route.ts +6 -0
- package/templates/app/src/app/api/tools/route.ts +65 -0
- package/templates/app/src/app/api/uploads/cleanup/route.ts +29 -0
- package/templates/app/src/app/api/uploads/route.ts +77 -0
- package/templates/app/src/app/auth/callback/route.ts +19 -0
- package/templates/app/src/app/globals.css +115 -0
- package/templates/app/src/app/layout.tsx +24 -0
- package/templates/app/src/app/loading.tsx +16 -0
- package/templates/app/src/app/login/page.tsx +64 -0
- package/templates/app/src/app/not-authorized/page.tsx +33 -0
- package/templates/app/src/app/runs/page.tsx +55 -0
- package/templates/app/src/app/schedules/page.tsx +110 -0
- package/templates/app/src/app/settings/page.tsx +1294 -0
- package/templates/app/src/app/skills/page.tsx +7 -0
- package/templates/app/src/components/agent-card.tsx +58 -0
- package/templates/app/src/components/agent-grid.tsx +90 -0
- package/templates/app/src/components/auth/auth-context.tsx +79 -0
- package/templates/app/src/components/chat-thread.tsx +50 -0
- package/templates/app/src/components/chat-view.tsx +670 -0
- package/templates/app/src/components/commands-browser.tsx +349 -0
- package/templates/app/src/components/create-agent-modal.tsx +388 -0
- package/templates/app/src/components/governance-dashboard.tsx +397 -0
- package/templates/app/src/components/icons.tsx +401 -0
- package/templates/app/src/components/layout/agent-sidebar.tsx +504 -0
- package/templates/app/src/components/layout/app-shell.tsx +29 -0
- package/templates/app/src/components/layout/nav.tsx +87 -0
- package/templates/app/src/components/layout/overview-inner.tsx +14 -0
- package/templates/app/src/components/layout/profile-menu.tsx +95 -0
- package/templates/app/src/components/layout/sidebar.tsx +30 -0
- package/templates/app/src/components/markdown.tsx +57 -0
- package/templates/app/src/components/message-bar.tsx +161 -0
- package/templates/app/src/components/notifications/notification-bell.tsx +104 -0
- package/templates/app/src/components/notifications/notification-panel.tsx +116 -0
- package/templates/app/src/components/overview/overview-content.tsx +287 -0
- package/templates/app/src/components/overview/overview-context.tsx +88 -0
- package/templates/app/src/components/preferences-modal.tsx +112 -0
- package/templates/app/src/components/run-form.tsx +73 -0
- package/templates/app/src/components/run-history-table.tsx +226 -0
- package/templates/app/src/components/run-output.tsx +187 -0
- package/templates/app/src/components/schedule-form.tsx +148 -0
- package/templates/app/src/components/skills-browser.tsx +338 -0
- package/templates/app/src/components/tool-tooltip.tsx +82 -0
- package/templates/app/src/hooks/use-sse.ts +115 -0
- package/templates/app/src/instrumentation.ts +9 -0
- package/templates/app/src/lib/agent-cache.ts +19 -0
- package/templates/app/src/lib/agent-runner.ts +411 -0
- package/templates/app/src/lib/agents.ts +168 -0
- package/templates/app/src/lib/ai.ts +40 -0
- package/templates/app/src/lib/approval-store.ts +70 -0
- package/templates/app/src/lib/auth-guard.ts +116 -0
- package/templates/app/src/lib/capabilities.ts +191 -0
- package/templates/app/src/lib/line-diff.ts +96 -0
- package/templates/app/src/lib/queue.ts +22 -0
- package/templates/app/src/lib/redis.ts +12 -0
- package/templates/app/src/lib/role-permissions.ts +166 -0
- package/templates/app/src/lib/run-agent.ts +442 -0
- package/templates/app/src/lib/supabase-browser.ts +8 -0
- package/templates/app/src/lib/supabase-middleware.ts +63 -0
- package/templates/app/src/lib/supabase-server.ts +28 -0
- package/templates/app/src/lib/supabase.ts +6 -0
- package/templates/app/src/lib/tool-descriptions.ts +29 -0
- package/templates/app/src/lib/types.ts +73 -0
- package/templates/app/src/lib/typewriter-animation.ts +159 -0
- package/templates/app/src/middleware.ts +13 -0
- package/templates/app/tsconfig.json +21 -0
- package/templates/app/uploads/.gitkeep +0 -0
- package/templates/app/worker/index.ts +342 -0
- package/templates/claude/agents/ai-trends-scout.md +66 -0
- package/templates/claude/commands/add-to-todos.md +56 -0
- package/templates/claude/commands/check-todos.md +56 -0
- package/templates/claude/hooks/auto-approve-safe.sh +34 -0
- package/templates/claude/hooks/auto-format.sh +25 -0
- package/templates/claude/hooks/block-destructive.sh +32 -0
- package/templates/claude/hooks/compaction-preserver.sh +16 -0
- package/templates/claude/hooks/notify.sh +26 -0
- package/templates/claude/settings.local.json +66 -0
- package/templates/claude/skills/frontend-design/SKILL.md +127 -0
- package/templates/claude/skills/frontend-design/reference/color-and-contrast.md +132 -0
- package/templates/claude/skills/frontend-design/reference/interaction-design.md +123 -0
- package/templates/claude/skills/frontend-design/reference/motion-design.md +99 -0
- package/templates/claude/skills/frontend-design/reference/responsive-design.md +114 -0
- package/templates/claude/skills/frontend-design/reference/spatial-design.md +100 -0
- package/templates/claude/skills/frontend-design/reference/typography.md +131 -0
- package/templates/claude/skills/frontend-design/reference/ux-writing.md +107 -0
- package/templates/claude/skills/gws-admin-reports/SKILL.md +57 -0
- package/templates/claude/skills/gws-calendar/SKILL.md +108 -0
- package/templates/claude/skills/gws-calendar-agenda/SKILL.md +52 -0
- package/templates/claude/skills/gws-calendar-insert/SKILL.md +55 -0
- package/templates/claude/skills/gws-chat/SKILL.md +73 -0
- package/templates/claude/skills/gws-chat-send/SKILL.md +49 -0
- package/templates/claude/skills/gws-classroom/SKILL.md +75 -0
- package/templates/claude/skills/gws-docs/SKILL.md +48 -0
- package/templates/claude/skills/gws-docs-write/SKILL.md +49 -0
- package/templates/claude/skills/gws-drive/SKILL.md +137 -0
- package/templates/claude/skills/gws-drive-upload/SKILL.md +52 -0
- package/templates/claude/skills/gws-events/SKILL.md +67 -0
- package/templates/claude/skills/gws-events-renew/SKILL.md +48 -0
- package/templates/claude/skills/gws-events-subscribe/SKILL.md +59 -0
- package/templates/claude/skills/gws-forms/SKILL.md +45 -0
- package/templates/claude/skills/gws-gmail/SKILL.md +59 -0
- package/templates/claude/skills/gws-gmail-forward/SKILL.md +53 -0
- package/templates/claude/skills/gws-gmail-reply/SKILL.md +56 -0
- package/templates/claude/skills/gws-gmail-reply-all/SKILL.md +60 -0
- package/templates/claude/skills/gws-gmail-send/SKILL.md +55 -0
- package/templates/claude/skills/gws-gmail-triage/SKILL.md +50 -0
- package/templates/claude/skills/gws-gmail-watch/SKILL.md +58 -0
- package/templates/claude/skills/gws-keep/SKILL.md +48 -0
- package/templates/claude/skills/gws-meet/SKILL.md +51 -0
- package/templates/claude/skills/gws-modelarmor/SKILL.md +42 -0
- package/templates/claude/skills/gws-modelarmor-create-template/SKILL.md +53 -0
- package/templates/claude/skills/gws-modelarmor-sanitize-prompt/SKILL.md +48 -0
- package/templates/claude/skills/gws-modelarmor-sanitize-response/SKILL.md +48 -0
- package/templates/claude/skills/gws-people/SKILL.md +67 -0
- package/templates/claude/skills/gws-shared/SKILL.md +66 -0
- package/templates/claude/skills/gws-sheets/SKILL.md +53 -0
- package/templates/claude/skills/gws-sheets-append/SKILL.md +51 -0
- package/templates/claude/skills/gws-sheets-read/SKILL.md +47 -0
- package/templates/claude/skills/gws-slides/SKILL.md +43 -0
- package/templates/claude/skills/gws-tasks/SKILL.md +56 -0
- package/templates/claude/skills/gws-workflow/SKILL.md +44 -0
- package/templates/claude/skills/gws-workflow-email-to-task/SKILL.md +47 -0
- package/templates/claude/skills/gws-workflow-file-announce/SKILL.md +50 -0
- package/templates/claude/skills/gws-workflow-meeting-prep/SKILL.md +47 -0
- package/templates/claude/skills/gws-workflow-standup-report/SKILL.md +46 -0
- package/templates/claude/skills/gws-workflow-weekly-digest/SKILL.md +46 -0
- package/templates/claude/skills/persona-content-creator/SKILL.md +33 -0
- package/templates/claude/skills/persona-customer-support/SKILL.md +34 -0
- package/templates/claude/skills/persona-event-coordinator/SKILL.md +35 -0
- package/templates/claude/skills/persona-exec-assistant/SKILL.md +35 -0
- package/templates/claude/skills/persona-hr-coordinator/SKILL.md +33 -0
- package/templates/claude/skills/persona-it-admin/SKILL.md +30 -0
- package/templates/claude/skills/persona-project-manager/SKILL.md +35 -0
- package/templates/claude/skills/persona-researcher/SKILL.md +33 -0
- package/templates/claude/skills/persona-sales-ops/SKILL.md +35 -0
- package/templates/claude/skills/persona-team-lead/SKILL.md +36 -0
- package/templates/claude/skills/recipe-backup-sheet-as-csv/SKILL.md +25 -0
- package/templates/claude/skills/recipe-batch-invite-to-event/SKILL.md +25 -0
- package/templates/claude/skills/recipe-block-focus-time/SKILL.md +24 -0
- package/templates/claude/skills/recipe-bulk-download-folder/SKILL.md +25 -0
- package/templates/claude/skills/recipe-collect-form-responses/SKILL.md +25 -0
- package/templates/claude/skills/recipe-compare-sheet-tabs/SKILL.md +25 -0
- package/templates/claude/skills/recipe-copy-sheet-for-new-month/SKILL.md +25 -0
- package/templates/claude/skills/recipe-create-classroom-course/SKILL.md +25 -0
- package/templates/claude/skills/recipe-create-doc-from-template/SKILL.md +29 -0
- package/templates/claude/skills/recipe-create-events-from-sheet/SKILL.md +24 -0
- package/templates/claude/skills/recipe-create-expense-tracker/SKILL.md +26 -0
- package/templates/claude/skills/recipe-create-feedback-form/SKILL.md +25 -0
- package/templates/claude/skills/recipe-create-gmail-filter/SKILL.md +26 -0
- package/templates/claude/skills/recipe-create-meet-space/SKILL.md +25 -0
- package/templates/claude/skills/recipe-create-presentation/SKILL.md +25 -0
- package/templates/claude/skills/recipe-create-shared-drive/SKILL.md +25 -0
- package/templates/claude/skills/recipe-create-task-list/SKILL.md +26 -0
- package/templates/claude/skills/recipe-create-vacation-responder/SKILL.md +25 -0
- package/templates/claude/skills/recipe-draft-email-from-doc/SKILL.md +25 -0
- package/templates/claude/skills/recipe-email-drive-link/SKILL.md +25 -0
- package/templates/claude/skills/recipe-find-free-time/SKILL.md +25 -0
- package/templates/claude/skills/recipe-find-large-files/SKILL.md +24 -0
- package/templates/claude/skills/recipe-forward-labeled-emails/SKILL.md +27 -0
- package/templates/claude/skills/recipe-generate-report-from-sheet/SKILL.md +34 -0
- package/templates/claude/skills/recipe-label-and-archive-emails/SKILL.md +25 -0
- package/templates/claude/skills/recipe-log-deal-update/SKILL.md +25 -0
- package/templates/claude/skills/recipe-organize-drive-folder/SKILL.md +26 -0
- package/templates/claude/skills/recipe-plan-weekly-schedule/SKILL.md +26 -0
- package/templates/claude/skills/recipe-post-mortem-setup/SKILL.md +25 -0
- package/templates/claude/skills/recipe-reschedule-meeting/SKILL.md +25 -0
- package/templates/claude/skills/recipe-review-meet-participants/SKILL.md +25 -0
- package/templates/claude/skills/recipe-review-overdue-tasks/SKILL.md +25 -0
- package/templates/claude/skills/recipe-save-email-attachments/SKILL.md +26 -0
- package/templates/claude/skills/recipe-save-email-to-doc/SKILL.md +29 -0
- package/templates/claude/skills/recipe-schedule-recurring-event/SKILL.md +24 -0
- package/templates/claude/skills/recipe-send-team-announcement/SKILL.md +24 -0
- package/templates/claude/skills/recipe-share-doc-and-notify/SKILL.md +25 -0
- package/templates/claude/skills/recipe-share-event-materials/SKILL.md +25 -0
- package/templates/claude/skills/recipe-share-folder-with-team/SKILL.md +26 -0
- package/templates/claude/skills/recipe-sync-contacts-to-sheet/SKILL.md +25 -0
- package/templates/claude/skills/recipe-watch-drive-changes/SKILL.md +25 -0
- 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
|
+
}
|