stagent 0.5.0 → 0.6.1
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/README.md +8 -8
- package/dist/cli.js +146 -2
- package/docs/.coverage-gaps.json +21 -0
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +36 -14
- package/docs/features/chat.md +33 -56
- package/docs/features/cost-usage.md +14 -10
- package/docs/features/dashboard-kanban.md +30 -13
- package/docs/features/delivery-channels.md +198 -0
- package/docs/features/design-system.md +10 -10
- package/docs/features/documents.md +8 -8
- package/docs/features/home-workspace.md +20 -15
- package/docs/features/inbox-notifications.md +22 -10
- package/docs/features/keyboard-navigation.md +11 -11
- package/docs/features/monitoring.md +1 -1
- package/docs/features/playbook.md +30 -32
- package/docs/features/profiles.md +33 -11
- package/docs/features/projects.md +2 -2
- package/docs/features/provider-runtimes.md +58 -14
- package/docs/features/schedules.md +70 -40
- package/docs/features/settings.md +74 -46
- package/docs/features/shared-components.md +7 -15
- package/docs/features/tool-permissions.md +9 -9
- package/docs/features/workflows.md +32 -21
- package/docs/getting-started.md +33 -9
- package/docs/index.md +25 -16
- package/docs/journeys/developer.md +124 -207
- package/docs/journeys/personal-use.md +70 -79
- package/docs/journeys/power-user.md +107 -151
- package/docs/journeys/work-use.md +81 -113
- package/docs/manifest.json +77 -45
- package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
- package/docs/use-cases/agency-operator.md +84 -0
- package/docs/use-cases/solo-founder.md +75 -0
- package/docs/why-stagent.md +59 -0
- package/package.json +10 -3
- package/src/app/api/channels/[id]/route.ts +104 -0
- package/src/app/api/channels/[id]/test/route.ts +52 -0
- package/src/app/api/channels/inbound/slack/route.ts +116 -0
- package/src/app/api/channels/inbound/telegram/poll/route.ts +140 -0
- package/src/app/api/channels/inbound/telegram/route.ts +87 -0
- package/src/app/api/channels/route.ts +72 -0
- package/src/app/api/chat/conversations/route.ts +15 -0
- package/src/app/api/chat/entities/search/route.ts +46 -31
- package/src/app/api/data/clear/route.ts +4 -0
- package/src/app/api/data/seed/route.ts +4 -0
- package/src/app/api/documents/route.ts +36 -6
- package/src/app/api/environment/profiles/suggest/route.ts +19 -3
- package/src/app/api/environment/scan/route.ts +8 -1
- package/src/app/api/handoffs/[id]/route.ts +76 -0
- package/src/app/api/handoffs/route.ts +89 -0
- package/src/app/api/memory/route.ts +181 -0
- package/src/app/api/profiles/[id]/route.ts +16 -1
- package/src/app/api/profiles/[id]/test/route.ts +4 -0
- package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
- package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
- package/src/app/api/profiles/assist/route.ts +35 -0
- package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
- package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
- package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
- package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
- package/src/app/api/profiles/import-repo/route.ts +29 -0
- package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
- package/src/app/api/profiles/route.ts +73 -22
- package/src/app/api/runtimes/ollama/route.ts +86 -0
- package/src/app/api/runtimes/suggest/route.ts +29 -0
- package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
- package/src/app/api/schedules/[id]/route.ts +41 -3
- package/src/app/api/schedules/parse/route.ts +66 -0
- package/src/app/api/schedules/route.ts +71 -12
- package/src/app/api/settings/author-default/route.ts +7 -0
- package/src/app/api/settings/learning/route.ts +41 -0
- package/src/app/api/settings/ollama/route.ts +34 -0
- package/src/app/api/settings/providers/route.ts +57 -0
- package/src/app/api/settings/routing/route.ts +24 -0
- package/src/app/api/settings/web-search/route.ts +28 -0
- package/src/app/api/tasks/[id]/execute/route.ts +13 -1
- package/src/app/api/tasks/[id]/respond/route.ts +23 -1
- package/src/app/documents/page.tsx +3 -0
- package/src/app/environment/page.tsx +8 -1
- package/src/app/settings/page.tsx +10 -4
- package/src/app/workflows/[id]/edit/page.tsx +2 -0
- package/src/app/workflows/new/page.tsx +2 -0
- package/src/components/chat/chat-command-popover.tsx +22 -19
- package/src/components/chat/chat-input.tsx +5 -0
- package/src/components/chat/chat-model-selector.tsx +42 -1
- package/src/components/chat/chat-shell.tsx +2 -0
- package/src/components/dashboard/welcome-landing.tsx +9 -9
- package/src/components/environment/artifact-card.tsx +27 -1
- package/src/components/environment/environment-dashboard.tsx +50 -2
- package/src/components/environment/environment-summary-card.tsx +5 -2
- package/src/components/environment/suggested-profiles.tsx +117 -52
- package/src/components/handoffs/handoff-approval-card.tsx +159 -0
- package/src/components/memory/memory-browser.tsx +315 -0
- package/src/components/profiles/learned-context-panel.tsx +4 -4
- package/src/components/profiles/profile-assist-panel.tsx +512 -0
- package/src/components/profiles/profile-browser.tsx +109 -8
- package/src/components/profiles/profile-card.tsx +29 -1
- package/src/components/profiles/profile-detail-view.tsx +200 -28
- package/src/components/profiles/profile-form-view.tsx +220 -82
- package/src/components/profiles/repo-import-wizard.tsx +648 -0
- package/src/components/profiles/smoke-test-editor.tsx +106 -0
- package/src/components/schedules/schedule-create-sheet.tsx +9 -1
- package/src/components/schedules/schedule-form.tsx +348 -9
- package/src/components/schedules/schedule-list.tsx +15 -2
- package/src/components/settings/auth-method-selector.tsx +7 -1
- package/src/components/settings/budget-guardrails-section.tsx +111 -48
- package/src/components/settings/channels-section.tsx +526 -0
- package/src/components/settings/chat-settings-section.tsx +27 -1
- package/src/components/settings/data-management-section.tsx +8 -6
- package/src/components/settings/learning-context-section.tsx +124 -0
- package/src/components/settings/ollama-section.tsx +270 -0
- package/src/components/settings/providers-runtimes-section.tsx +499 -0
- package/src/components/settings/web-search-section.tsx +101 -0
- package/src/components/shared/tag-input.tsx +156 -0
- package/src/components/tasks/kanban-board.tsx +32 -0
- package/src/components/tasks/kanban-column.tsx +4 -2
- package/src/components/tasks/task-card.tsx +1 -0
- package/src/components/tasks/task-chip-bar.tsx +6 -1
- package/src/components/tasks/task-create-panel.tsx +55 -5
- package/src/components/workflows/workflow-form-view.tsx +38 -3
- package/src/hooks/use-chat-autocomplete.ts +24 -26
- package/src/hooks/use-project-skills.ts +66 -0
- package/src/hooks/use-tag-suggestions.ts +31 -0
- package/src/instrumentation.ts +4 -1
- package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
- package/src/lib/agents/agentic-loop.ts +235 -0
- package/src/lib/agents/browser-mcp.ts +59 -4
- package/src/lib/agents/claude-agent.ts +27 -200
- package/src/lib/agents/handoff/bus.ts +164 -0
- package/src/lib/agents/handoff/governance.ts +47 -0
- package/src/lib/agents/handoff/types.ts +16 -0
- package/src/lib/agents/learned-context.ts +27 -7
- package/src/lib/agents/memory/decay.ts +61 -0
- package/src/lib/agents/memory/extractor.ts +181 -0
- package/src/lib/agents/memory/retrieval.ts +96 -0
- package/src/lib/agents/memory/types.ts +6 -0
- package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
- package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
- package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
- package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
- package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
- package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
- package/src/lib/agents/profiles/project-profiles.ts +193 -0
- package/src/lib/agents/profiles/registry.ts +130 -6
- package/src/lib/agents/profiles/types.ts +28 -0
- package/src/lib/agents/router.ts +174 -2
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
- package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
- package/src/lib/agents/runtime/catalog.ts +57 -2
- package/src/lib/agents/runtime/claude.ts +205 -1
- package/src/lib/agents/runtime/index.ts +22 -0
- package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
- package/src/lib/agents/runtime/openai-direct.ts +514 -0
- package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
- package/src/lib/agents/runtime/types.ts +2 -0
- package/src/lib/agents/tool-permissions.ts +203 -0
- package/src/lib/channels/gateway.ts +321 -0
- package/src/lib/channels/poller.ts +268 -0
- package/src/lib/channels/registry.ts +90 -0
- package/src/lib/channels/slack-adapter.ts +188 -0
- package/src/lib/channels/telegram-adapter.ts +218 -0
- package/src/lib/channels/types.ts +75 -0
- package/src/lib/channels/webhook-adapter.ts +74 -0
- package/src/lib/chat/context-builder.ts +22 -2
- package/src/lib/chat/engine.ts +95 -13
- package/src/lib/chat/ollama-engine.ts +198 -0
- package/src/lib/chat/stagent-tools.ts +106 -20
- package/src/lib/chat/tool-catalog.ts +24 -0
- package/src/lib/chat/tool-registry.ts +90 -0
- package/src/lib/chat/tools/chat-history-tools.ts +4 -4
- package/src/lib/chat/tools/document-tools.ts +7 -7
- package/src/lib/chat/tools/handoff-tools.ts +70 -0
- package/src/lib/chat/tools/notification-tools.ts +4 -4
- package/src/lib/chat/tools/profile-tools.ts +3 -3
- package/src/lib/chat/tools/project-tools.ts +3 -3
- package/src/lib/chat/tools/schedule-tools.ts +29 -13
- package/src/lib/chat/tools/settings-tools.ts +2 -2
- package/src/lib/chat/tools/task-tools.ts +66 -11
- package/src/lib/chat/tools/usage-tools.ts +2 -2
- package/src/lib/chat/tools/workflow-tools.ts +8 -8
- package/src/lib/chat/types.ts +11 -5
- package/src/lib/constants/known-tools.ts +19 -0
- package/src/lib/constants/prose-styles.ts +1 -1
- package/src/lib/constants/settings.ts +7 -0
- package/src/lib/data/channel-bindings.ts +85 -0
- package/src/lib/data/clear.ts +22 -0
- package/src/lib/data/profile-test-results.ts +48 -0
- package/src/lib/data/seed-data/conversations.ts +196 -0
- package/src/lib/data/seed-data/learned-context.ts +99 -0
- package/src/lib/data/seed-data/notifications.ts +54 -1
- package/src/lib/data/seed-data/profile-test-results.ts +96 -0
- package/src/lib/data/seed-data/repo-imports.ts +51 -0
- package/src/lib/data/seed-data/views.ts +60 -0
- package/src/lib/data/seed.ts +51 -0
- package/src/lib/db/bootstrap.ts +162 -0
- package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
- package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
- package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
- package/src/lib/db/schema.ts +190 -1
- package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
- package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
- package/src/lib/environment/auto-scan.ts +48 -0
- package/src/lib/environment/data.ts +25 -0
- package/src/lib/environment/profile-generator.ts +40 -10
- package/src/lib/environment/profile-linker.ts +143 -0
- package/src/lib/environment/profile-rules.ts +96 -0
- package/src/lib/import/dedup.ts +149 -0
- package/src/lib/import/format-adapter.ts +631 -0
- package/src/lib/import/github-api.ts +219 -0
- package/src/lib/import/repo-scanner.ts +251 -0
- package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
- package/src/lib/schedules/active-hours.ts +120 -0
- package/src/lib/schedules/heartbeat-parser.ts +224 -0
- package/src/lib/schedules/heartbeat-prompt.ts +153 -0
- package/src/lib/schedules/nlp-parser.ts +357 -0
- package/src/lib/schedules/scheduler.ts +218 -3
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
- package/src/lib/settings/helpers.ts +6 -0
- package/src/lib/settings/routing.ts +24 -0
- package/src/lib/settings/runtime-setup.ts +28 -1
- package/src/lib/usage/ledger.ts +2 -1
- package/src/lib/validators/__tests__/settings.test.ts +9 -0
- package/src/lib/validators/profile.ts +39 -0
- package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
- package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
- package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
- package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
- package/src/lib/workflows/blueprints/builtins/lead-research-pipeline.yaml +82 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool permission handling for task-based runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from claude-agent.ts so that all task runtimes (Claude SDK,
|
|
5
|
+
* Anthropic Direct, OpenAI Direct) can reuse the same HITL permission
|
|
6
|
+
* logic. Uses DB notification polling — the Inbox UI writes responses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { db } from "@/lib/db";
|
|
11
|
+
import { notifications } from "@/lib/db/schema";
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
13
|
+
import type { CanUseToolPolicy } from "./profiles/types";
|
|
14
|
+
import { isExaTool, isExaReadOnly } from "./browser-mcp";
|
|
15
|
+
|
|
16
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export const toolPermissionResponseSchema = z.object({
|
|
19
|
+
behavior: z.enum(["allow", "deny"]),
|
|
20
|
+
updatedInput: z.unknown().optional(),
|
|
21
|
+
message: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type ToolPermissionResponse = z.infer<typeof toolPermissionResponseSchema>;
|
|
25
|
+
|
|
26
|
+
// ── Caches ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const inFlightPermissionRequests = new Map<string, Promise<ToolPermissionResponse>>();
|
|
29
|
+
const settledPermissionRequests = new Map<string, ToolPermissionResponse>();
|
|
30
|
+
|
|
31
|
+
// ── Response builders ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export function buildAllowedToolPermissionResponse(
|
|
34
|
+
input: Record<string, unknown>,
|
|
35
|
+
): ToolPermissionResponse {
|
|
36
|
+
return { behavior: "allow", updatedInput: input };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeToolPermissionResponse(
|
|
40
|
+
response: ToolPermissionResponse,
|
|
41
|
+
input: Record<string, unknown>,
|
|
42
|
+
): ToolPermissionResponse {
|
|
43
|
+
if (response.behavior !== "allow" || response.updatedInput !== undefined) {
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
return { ...response, updatedInput: input };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Cache helpers ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function buildPermissionCacheKey(
|
|
52
|
+
taskId: string,
|
|
53
|
+
toolName: string,
|
|
54
|
+
input: Record<string, unknown>,
|
|
55
|
+
): string {
|
|
56
|
+
return `${taskId}::${toolName}::${JSON.stringify(input)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearPermissionCache(taskId: string) {
|
|
60
|
+
const prefix = `${taskId}::`;
|
|
61
|
+
|
|
62
|
+
for (const key of inFlightPermissionRequests.keys()) {
|
|
63
|
+
if (key.startsWith(prefix)) inFlightPermissionRequests.delete(key);
|
|
64
|
+
}
|
|
65
|
+
for (const key of settledPermissionRequests.keys()) {
|
|
66
|
+
if (key.startsWith(prefix)) settledPermissionRequests.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── DB polling ───────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export async function waitForToolPermissionResponse(
|
|
73
|
+
notificationId: string,
|
|
74
|
+
): Promise<ToolPermissionResponse> {
|
|
75
|
+
const deadline = Date.now() + 55_000;
|
|
76
|
+
const pollInterval = 1500;
|
|
77
|
+
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
const [notification] = await db
|
|
80
|
+
.select()
|
|
81
|
+
.from(notifications)
|
|
82
|
+
.where(eq(notifications.id, notificationId));
|
|
83
|
+
|
|
84
|
+
if (notification?.response) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(notification.response);
|
|
87
|
+
const validated = toolPermissionResponseSchema.safeParse(parsed);
|
|
88
|
+
if (validated.success) return validated.data;
|
|
89
|
+
console.error("[tool-permissions] Invalid permission response shape:", validated.error.message);
|
|
90
|
+
return { behavior: "deny", message: "Invalid response format" };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error("[tool-permissions] Failed to parse permission response:", err);
|
|
93
|
+
return { behavior: "deny", message: "Invalid response format" };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { behavior: "deny", message: "Permission request timed out" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Main permission handler ──────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handle tool permission for task-based runtimes.
|
|
107
|
+
*
|
|
108
|
+
* Permission layers:
|
|
109
|
+
* 1. Profile canUseToolPolicy (autoApprove / autoDeny)
|
|
110
|
+
* 1.5. External MCP read-only tools (Exa search)
|
|
111
|
+
* 2. Saved user permissions (settings-based patterns)
|
|
112
|
+
* 3. Request deduplication cache
|
|
113
|
+
* 4. DB notification + polling (HITL)
|
|
114
|
+
*/
|
|
115
|
+
export async function handleToolPermission(
|
|
116
|
+
taskId: string,
|
|
117
|
+
toolName: string,
|
|
118
|
+
input: Record<string, unknown>,
|
|
119
|
+
canUseToolPolicy?: CanUseToolPolicy,
|
|
120
|
+
): Promise<ToolPermissionResponse> {
|
|
121
|
+
const isQuestion = toolName === "AskUserQuestion";
|
|
122
|
+
|
|
123
|
+
// Layer 1: Profile-level canUseToolPolicy — fastest check, no I/O
|
|
124
|
+
if (!isQuestion && canUseToolPolicy) {
|
|
125
|
+
if (canUseToolPolicy.autoApprove?.includes(toolName)) {
|
|
126
|
+
return buildAllowedToolPermissionResponse(input);
|
|
127
|
+
}
|
|
128
|
+
if (canUseToolPolicy.autoDeny?.includes(toolName)) {
|
|
129
|
+
return { behavior: "deny", message: `Profile policy denies ${toolName}` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Layer 1.5: External MCP read-only tools — auto-approve without I/O
|
|
134
|
+
if (!isQuestion && isExaTool(toolName) && isExaReadOnly(toolName)) {
|
|
135
|
+
return buildAllowedToolPermissionResponse(input);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Layer 2: Saved user permissions — skip notification for pre-approved tools
|
|
139
|
+
if (!isQuestion) {
|
|
140
|
+
const { isToolAllowed } = await import("@/lib/settings/permissions");
|
|
141
|
+
if (await isToolAllowed(toolName, input)) {
|
|
142
|
+
return buildAllowedToolPermissionResponse(input);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Layer 3 + 4: Deduplication cache + DB notification
|
|
147
|
+
if (!isQuestion) {
|
|
148
|
+
const cacheKey = buildPermissionCacheKey(taskId, toolName, input);
|
|
149
|
+
const settledResponse = settledPermissionRequests.get(cacheKey);
|
|
150
|
+
if (settledResponse) {
|
|
151
|
+
return normalizeToolPermissionResponse(settledResponse, input);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const pendingRequest = inFlightPermissionRequests.get(cacheKey);
|
|
155
|
+
if (pendingRequest) return pendingRequest;
|
|
156
|
+
|
|
157
|
+
const requestPromise = (async () => {
|
|
158
|
+
const notificationId = crypto.randomUUID();
|
|
159
|
+
|
|
160
|
+
await db.insert(notifications).values({
|
|
161
|
+
id: notificationId,
|
|
162
|
+
taskId,
|
|
163
|
+
type: "permission_required",
|
|
164
|
+
title: `Permission required: ${toolName}`,
|
|
165
|
+
body: JSON.stringify(input).slice(0, 1000),
|
|
166
|
+
toolName,
|
|
167
|
+
toolInput: JSON.stringify(input),
|
|
168
|
+
createdAt: new Date(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const response = normalizeToolPermissionResponse(
|
|
172
|
+
await waitForToolPermissionResponse(notificationId),
|
|
173
|
+
input,
|
|
174
|
+
);
|
|
175
|
+
settledPermissionRequests.set(cacheKey, response);
|
|
176
|
+
return response;
|
|
177
|
+
})();
|
|
178
|
+
|
|
179
|
+
inFlightPermissionRequests.set(cacheKey, requestPromise);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
return await requestPromise;
|
|
183
|
+
} finally {
|
|
184
|
+
inFlightPermissionRequests.delete(cacheKey);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// AskUserQuestion fallback — always creates notification
|
|
189
|
+
const notificationId = crypto.randomUUID();
|
|
190
|
+
|
|
191
|
+
await db.insert(notifications).values({
|
|
192
|
+
id: notificationId,
|
|
193
|
+
taskId,
|
|
194
|
+
type: isQuestion ? "agent_message" : "permission_required",
|
|
195
|
+
title: isQuestion ? "Agent has a question" : `Permission required: ${toolName}`,
|
|
196
|
+
body: JSON.stringify(input).slice(0, 1000),
|
|
197
|
+
toolName,
|
|
198
|
+
toolInput: JSON.stringify(input),
|
|
199
|
+
createdAt: new Date(),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return waitForToolPermissionResponse(notificationId);
|
|
203
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Gateway — bridges inbound channel messages to the chat engine.
|
|
3
|
+
*
|
|
4
|
+
* Flow: Inbound webhook → gateway → sendMessage() (existing chat engine)
|
|
5
|
+
* → accumulate deltas → sendReply() back to channel thread.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
import { db } from "@/lib/db";
|
|
10
|
+
import { channelConfigs } from "@/lib/db/schema";
|
|
11
|
+
import { eq } from "drizzle-orm";
|
|
12
|
+
import {
|
|
13
|
+
getBindingByConfigAndThread,
|
|
14
|
+
createBinding,
|
|
15
|
+
setPendingRequest,
|
|
16
|
+
} from "@/lib/data/channel-bindings";
|
|
17
|
+
import { createConversation } from "@/lib/data/chat";
|
|
18
|
+
import { sendMessage } from "@/lib/chat/engine";
|
|
19
|
+
import {
|
|
20
|
+
resolvePendingRequest,
|
|
21
|
+
type ToolPermissionResponse,
|
|
22
|
+
} from "@/lib/chat/permission-bridge";
|
|
23
|
+
import { getChannelAdapter } from "./registry";
|
|
24
|
+
import type { ChannelMessage, InboundMessage } from "./types";
|
|
25
|
+
|
|
26
|
+
// ── Turn lock ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** In-memory lock: one turn per conversation at a time. */
|
|
29
|
+
const activeTurns = new Map<string, Promise<void>>();
|
|
30
|
+
|
|
31
|
+
// ── Permission reply parsing ───────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const APPROVE_PATTERNS = /^(approve|yes|allow|ok|y)$/i;
|
|
34
|
+
const DENY_PATTERNS = /^(deny|no|reject|n)$/i;
|
|
35
|
+
const ALWAYS_ALLOW_PATTERNS = /^(always\s*allow)$/i;
|
|
36
|
+
|
|
37
|
+
function parsePermissionReply(
|
|
38
|
+
text: string
|
|
39
|
+
): ToolPermissionResponse | null {
|
|
40
|
+
const trimmed = text.trim();
|
|
41
|
+
if (ALWAYS_ALLOW_PATTERNS.test(trimmed)) {
|
|
42
|
+
return { behavior: "allow" };
|
|
43
|
+
}
|
|
44
|
+
if (APPROVE_PATTERNS.test(trimmed)) {
|
|
45
|
+
return { behavior: "allow" };
|
|
46
|
+
}
|
|
47
|
+
if (DENY_PATTERNS.test(trimmed)) {
|
|
48
|
+
return { behavior: "deny" };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Default runtime/model for channel conversations ────────────────────
|
|
54
|
+
|
|
55
|
+
const DEFAULT_RUNTIME = "claude-code";
|
|
56
|
+
const DEFAULT_MODEL = "sonnet";
|
|
57
|
+
|
|
58
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface HandleInboundParams {
|
|
61
|
+
channelConfigId: string;
|
|
62
|
+
message: InboundMessage;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GatewayResult {
|
|
66
|
+
success: boolean;
|
|
67
|
+
conversationId?: string;
|
|
68
|
+
error?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Handle an inbound message from a channel.
|
|
73
|
+
*
|
|
74
|
+
* 1. Resolve or create binding (channel+thread → conversation)
|
|
75
|
+
* 2. Check turn lock
|
|
76
|
+
* 3. If pending permission request, treat as permission reply
|
|
77
|
+
* 4. Otherwise, feed to chat engine and send response back
|
|
78
|
+
*/
|
|
79
|
+
export async function handleInboundMessage(
|
|
80
|
+
params: HandleInboundParams
|
|
81
|
+
): Promise<GatewayResult> {
|
|
82
|
+
const { channelConfigId, message } = params;
|
|
83
|
+
|
|
84
|
+
// Fetch channel config
|
|
85
|
+
const config = await db
|
|
86
|
+
.select()
|
|
87
|
+
.from(channelConfigs)
|
|
88
|
+
.where(eq(channelConfigs.id, channelConfigId))
|
|
89
|
+
.get();
|
|
90
|
+
|
|
91
|
+
if (!config) {
|
|
92
|
+
return { success: false, error: "Channel config not found" };
|
|
93
|
+
}
|
|
94
|
+
if (config.status === "disabled") {
|
|
95
|
+
return { success: false, error: "Channel is disabled" };
|
|
96
|
+
}
|
|
97
|
+
if (config.direction !== "bidirectional") {
|
|
98
|
+
return { success: false, error: "Channel is outbound-only" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Skip bot messages to prevent loops
|
|
102
|
+
if (message.isBot) {
|
|
103
|
+
return { success: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve or create binding
|
|
107
|
+
let binding = getBindingByConfigAndThread(
|
|
108
|
+
channelConfigId,
|
|
109
|
+
message.externalThreadId ?? null
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!binding) {
|
|
113
|
+
// Create new conversation + binding
|
|
114
|
+
const conversation = await createConversation({
|
|
115
|
+
runtimeId: DEFAULT_RUNTIME,
|
|
116
|
+
modelId: DEFAULT_MODEL,
|
|
117
|
+
title: `Channel: ${config.name}`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const bindingId = randomUUID();
|
|
121
|
+
const now = new Date();
|
|
122
|
+
createBinding({
|
|
123
|
+
id: bindingId,
|
|
124
|
+
channelConfigId,
|
|
125
|
+
conversationId: conversation.id,
|
|
126
|
+
externalThreadId: message.externalThreadId ?? null,
|
|
127
|
+
runtimeId: DEFAULT_RUNTIME,
|
|
128
|
+
modelId: DEFAULT_MODEL,
|
|
129
|
+
status: "active",
|
|
130
|
+
createdAt: now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
binding = {
|
|
135
|
+
id: bindingId,
|
|
136
|
+
channelConfigId,
|
|
137
|
+
conversationId: conversation.id,
|
|
138
|
+
externalThreadId: message.externalThreadId ?? null,
|
|
139
|
+
runtimeId: DEFAULT_RUNTIME,
|
|
140
|
+
modelId: DEFAULT_MODEL,
|
|
141
|
+
profileId: null,
|
|
142
|
+
status: "active" as const,
|
|
143
|
+
pendingRequestId: null,
|
|
144
|
+
createdAt: now,
|
|
145
|
+
updatedAt: now,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const conversationId = binding.conversationId;
|
|
150
|
+
|
|
151
|
+
// Handle pending permission request
|
|
152
|
+
if (binding.pendingRequestId) {
|
|
153
|
+
const response = parsePermissionReply(message.text);
|
|
154
|
+
if (response) {
|
|
155
|
+
resolvePendingRequest(binding.pendingRequestId, response);
|
|
156
|
+
setPendingRequest(binding.id, null);
|
|
157
|
+
return { success: true, conversationId };
|
|
158
|
+
}
|
|
159
|
+
// Not a valid permission reply — send guidance
|
|
160
|
+
await sendChannelReply(
|
|
161
|
+
config,
|
|
162
|
+
message.externalThreadId,
|
|
163
|
+
"Please reply with **approve** or **deny** to the pending permission request."
|
|
164
|
+
);
|
|
165
|
+
return { success: true, conversationId };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check turn lock
|
|
169
|
+
if (activeTurns.has(conversationId)) {
|
|
170
|
+
await sendChannelReply(
|
|
171
|
+
config,
|
|
172
|
+
message.externalThreadId,
|
|
173
|
+
"Still processing your previous message. Please wait..."
|
|
174
|
+
);
|
|
175
|
+
return { success: true, conversationId };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Process the turn
|
|
179
|
+
const turnPromise = processTurn(
|
|
180
|
+
config,
|
|
181
|
+
binding,
|
|
182
|
+
message
|
|
183
|
+
);
|
|
184
|
+
activeTurns.set(conversationId, turnPromise);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await turnPromise;
|
|
188
|
+
} finally {
|
|
189
|
+
activeTurns.delete(conversationId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { success: true, conversationId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Turn processing ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
async function processTurn(
|
|
198
|
+
config: typeof channelConfigs.$inferSelect,
|
|
199
|
+
binding: {
|
|
200
|
+
id: string;
|
|
201
|
+
conversationId: string;
|
|
202
|
+
externalThreadId: string | null;
|
|
203
|
+
},
|
|
204
|
+
message: InboundMessage
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
let fullResponse = "";
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
for await (const event of sendMessage(binding.conversationId, message.text)) {
|
|
210
|
+
switch (event.type) {
|
|
211
|
+
case "delta":
|
|
212
|
+
fullResponse += event.content;
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case "permission_request": {
|
|
216
|
+
// Send permission prompt to channel
|
|
217
|
+
const prompt = formatPermissionPrompt(
|
|
218
|
+
event.toolName,
|
|
219
|
+
event.toolInput
|
|
220
|
+
);
|
|
221
|
+
await sendChannelReply(config, binding.externalThreadId, prompt);
|
|
222
|
+
|
|
223
|
+
// Track pending request on binding
|
|
224
|
+
setPendingRequest(binding.id, event.requestId);
|
|
225
|
+
|
|
226
|
+
// The stream is now blocked waiting for permission resolution.
|
|
227
|
+
// The next inbound message will resolve it via handleInboundMessage.
|
|
228
|
+
// We continue iterating — the generator will yield once unblocked.
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case "question": {
|
|
233
|
+
// Format questions for channel display
|
|
234
|
+
const questionText = event.questions
|
|
235
|
+
.map((q, i) => {
|
|
236
|
+
let line = `**${q.header || `Question ${i + 1}`}**: ${q.question}`;
|
|
237
|
+
if (q.options) {
|
|
238
|
+
line += "\n" + q.options.map((o) => ` - ${o.label}: ${o.description}`).join("\n");
|
|
239
|
+
}
|
|
240
|
+
return line;
|
|
241
|
+
})
|
|
242
|
+
.join("\n\n");
|
|
243
|
+
await sendChannelReply(config, binding.externalThreadId, questionText);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case "error":
|
|
248
|
+
fullResponse = `Error: ${event.message}`;
|
|
249
|
+
break;
|
|
250
|
+
|
|
251
|
+
case "done":
|
|
252
|
+
// Stream complete — break out of loop
|
|
253
|
+
break;
|
|
254
|
+
|
|
255
|
+
// Ignore: status, screenshot events (not meaningful in channel context)
|
|
256
|
+
default:
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (event.type === "done" || event.type === "error") break;
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
fullResponse = `Error processing message: ${errorMsg}`;
|
|
265
|
+
console.error(`[gateway] Error in processTurn for ${binding.conversationId}:`, err);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Send accumulated response back to channel
|
|
269
|
+
if (fullResponse.trim()) {
|
|
270
|
+
await sendChannelReply(config, binding.externalThreadId, fullResponse);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
async function sendChannelReply(
|
|
277
|
+
config: typeof channelConfigs.$inferSelect,
|
|
278
|
+
threadId: string | null | undefined,
|
|
279
|
+
body: string
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const adapter = getChannelAdapter(config.channelType);
|
|
282
|
+
let parsedConfig: Record<string, unknown>;
|
|
283
|
+
try {
|
|
284
|
+
parsedConfig = JSON.parse(config.config) as Record<string, unknown>;
|
|
285
|
+
} catch {
|
|
286
|
+
console.error(`[gateway] Invalid config JSON for channel ${config.id}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const message: ChannelMessage = {
|
|
291
|
+
subject: "",
|
|
292
|
+
body,
|
|
293
|
+
format: "markdown",
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Prefer sendReply (thread-aware) if available, otherwise fall back to send
|
|
297
|
+
if (adapter.sendReply && threadId) {
|
|
298
|
+
await adapter.sendReply(message, parsedConfig, threadId);
|
|
299
|
+
} else {
|
|
300
|
+
await adapter.send(message, parsedConfig);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatPermissionPrompt(
|
|
305
|
+
toolName: string,
|
|
306
|
+
toolInput: Record<string, unknown>
|
|
307
|
+
): string {
|
|
308
|
+
const inputPreview = JSON.stringify(toolInput, null, 2).slice(0, 500);
|
|
309
|
+
return [
|
|
310
|
+
`**Permission required:** \`${toolName}\``,
|
|
311
|
+
"",
|
|
312
|
+
"```json",
|
|
313
|
+
inputPreview,
|
|
314
|
+
"```",
|
|
315
|
+
"",
|
|
316
|
+
"Reply with:",
|
|
317
|
+
"- **approve** — allow this action",
|
|
318
|
+
"- **deny** — block this action",
|
|
319
|
+
"- **always allow** — allow this tool permanently",
|
|
320
|
+
].join("\n");
|
|
321
|
+
}
|