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.
Files changed (256) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +146 -2
  3. package/docs/.coverage-gaps.json +21 -0
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +36 -14
  6. package/docs/features/chat.md +33 -56
  7. package/docs/features/cost-usage.md +14 -10
  8. package/docs/features/dashboard-kanban.md +30 -13
  9. package/docs/features/delivery-channels.md +198 -0
  10. package/docs/features/design-system.md +10 -10
  11. package/docs/features/documents.md +8 -8
  12. package/docs/features/home-workspace.md +20 -15
  13. package/docs/features/inbox-notifications.md +22 -10
  14. package/docs/features/keyboard-navigation.md +11 -11
  15. package/docs/features/monitoring.md +1 -1
  16. package/docs/features/playbook.md +30 -32
  17. package/docs/features/profiles.md +33 -11
  18. package/docs/features/projects.md +2 -2
  19. package/docs/features/provider-runtimes.md +58 -14
  20. package/docs/features/schedules.md +70 -40
  21. package/docs/features/settings.md +74 -46
  22. package/docs/features/shared-components.md +7 -15
  23. package/docs/features/tool-permissions.md +9 -9
  24. package/docs/features/workflows.md +32 -21
  25. package/docs/getting-started.md +33 -9
  26. package/docs/index.md +25 -16
  27. package/docs/journeys/developer.md +124 -207
  28. package/docs/journeys/personal-use.md +70 -79
  29. package/docs/journeys/power-user.md +107 -151
  30. package/docs/journeys/work-use.md +81 -113
  31. package/docs/manifest.json +77 -45
  32. package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
  33. package/docs/use-cases/agency-operator.md +84 -0
  34. package/docs/use-cases/solo-founder.md +75 -0
  35. package/docs/why-stagent.md +59 -0
  36. package/package.json +10 -3
  37. package/src/app/api/channels/[id]/route.ts +104 -0
  38. package/src/app/api/channels/[id]/test/route.ts +52 -0
  39. package/src/app/api/channels/inbound/slack/route.ts +116 -0
  40. package/src/app/api/channels/inbound/telegram/poll/route.ts +140 -0
  41. package/src/app/api/channels/inbound/telegram/route.ts +87 -0
  42. package/src/app/api/channels/route.ts +72 -0
  43. package/src/app/api/chat/conversations/route.ts +15 -0
  44. package/src/app/api/chat/entities/search/route.ts +46 -31
  45. package/src/app/api/data/clear/route.ts +4 -0
  46. package/src/app/api/data/seed/route.ts +4 -0
  47. package/src/app/api/documents/route.ts +36 -6
  48. package/src/app/api/environment/profiles/suggest/route.ts +19 -3
  49. package/src/app/api/environment/scan/route.ts +8 -1
  50. package/src/app/api/handoffs/[id]/route.ts +76 -0
  51. package/src/app/api/handoffs/route.ts +89 -0
  52. package/src/app/api/memory/route.ts +181 -0
  53. package/src/app/api/profiles/[id]/route.ts +16 -1
  54. package/src/app/api/profiles/[id]/test/route.ts +4 -0
  55. package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
  56. package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
  57. package/src/app/api/profiles/assist/route.ts +35 -0
  58. package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
  59. package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
  60. package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
  61. package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
  62. package/src/app/api/profiles/import-repo/route.ts +29 -0
  63. package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
  64. package/src/app/api/profiles/route.ts +73 -22
  65. package/src/app/api/runtimes/ollama/route.ts +86 -0
  66. package/src/app/api/runtimes/suggest/route.ts +29 -0
  67. package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
  68. package/src/app/api/schedules/[id]/route.ts +41 -3
  69. package/src/app/api/schedules/parse/route.ts +66 -0
  70. package/src/app/api/schedules/route.ts +71 -12
  71. package/src/app/api/settings/author-default/route.ts +7 -0
  72. package/src/app/api/settings/learning/route.ts +41 -0
  73. package/src/app/api/settings/ollama/route.ts +34 -0
  74. package/src/app/api/settings/providers/route.ts +57 -0
  75. package/src/app/api/settings/routing/route.ts +24 -0
  76. package/src/app/api/settings/web-search/route.ts +28 -0
  77. package/src/app/api/tasks/[id]/execute/route.ts +13 -1
  78. package/src/app/api/tasks/[id]/respond/route.ts +23 -1
  79. package/src/app/documents/page.tsx +3 -0
  80. package/src/app/environment/page.tsx +8 -1
  81. package/src/app/settings/page.tsx +10 -4
  82. package/src/app/workflows/[id]/edit/page.tsx +2 -0
  83. package/src/app/workflows/new/page.tsx +2 -0
  84. package/src/components/chat/chat-command-popover.tsx +22 -19
  85. package/src/components/chat/chat-input.tsx +5 -0
  86. package/src/components/chat/chat-model-selector.tsx +42 -1
  87. package/src/components/chat/chat-shell.tsx +2 -0
  88. package/src/components/dashboard/welcome-landing.tsx +9 -9
  89. package/src/components/environment/artifact-card.tsx +27 -1
  90. package/src/components/environment/environment-dashboard.tsx +50 -2
  91. package/src/components/environment/environment-summary-card.tsx +5 -2
  92. package/src/components/environment/suggested-profiles.tsx +117 -52
  93. package/src/components/handoffs/handoff-approval-card.tsx +159 -0
  94. package/src/components/memory/memory-browser.tsx +315 -0
  95. package/src/components/profiles/learned-context-panel.tsx +4 -4
  96. package/src/components/profiles/profile-assist-panel.tsx +512 -0
  97. package/src/components/profiles/profile-browser.tsx +109 -8
  98. package/src/components/profiles/profile-card.tsx +29 -1
  99. package/src/components/profiles/profile-detail-view.tsx +200 -28
  100. package/src/components/profiles/profile-form-view.tsx +220 -82
  101. package/src/components/profiles/repo-import-wizard.tsx +648 -0
  102. package/src/components/profiles/smoke-test-editor.tsx +106 -0
  103. package/src/components/schedules/schedule-create-sheet.tsx +9 -1
  104. package/src/components/schedules/schedule-form.tsx +348 -9
  105. package/src/components/schedules/schedule-list.tsx +15 -2
  106. package/src/components/settings/auth-method-selector.tsx +7 -1
  107. package/src/components/settings/budget-guardrails-section.tsx +111 -48
  108. package/src/components/settings/channels-section.tsx +526 -0
  109. package/src/components/settings/chat-settings-section.tsx +27 -1
  110. package/src/components/settings/data-management-section.tsx +8 -6
  111. package/src/components/settings/learning-context-section.tsx +124 -0
  112. package/src/components/settings/ollama-section.tsx +270 -0
  113. package/src/components/settings/providers-runtimes-section.tsx +499 -0
  114. package/src/components/settings/web-search-section.tsx +101 -0
  115. package/src/components/shared/tag-input.tsx +156 -0
  116. package/src/components/tasks/kanban-board.tsx +32 -0
  117. package/src/components/tasks/kanban-column.tsx +4 -2
  118. package/src/components/tasks/task-card.tsx +1 -0
  119. package/src/components/tasks/task-chip-bar.tsx +6 -1
  120. package/src/components/tasks/task-create-panel.tsx +55 -5
  121. package/src/components/workflows/workflow-form-view.tsx +38 -3
  122. package/src/hooks/use-chat-autocomplete.ts +24 -26
  123. package/src/hooks/use-project-skills.ts +66 -0
  124. package/src/hooks/use-tag-suggestions.ts +31 -0
  125. package/src/instrumentation.ts +4 -1
  126. package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
  127. package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
  128. package/src/lib/agents/agentic-loop.ts +235 -0
  129. package/src/lib/agents/browser-mcp.ts +59 -4
  130. package/src/lib/agents/claude-agent.ts +27 -200
  131. package/src/lib/agents/handoff/bus.ts +164 -0
  132. package/src/lib/agents/handoff/governance.ts +47 -0
  133. package/src/lib/agents/handoff/types.ts +16 -0
  134. package/src/lib/agents/learned-context.ts +27 -7
  135. package/src/lib/agents/memory/decay.ts +61 -0
  136. package/src/lib/agents/memory/extractor.ts +181 -0
  137. package/src/lib/agents/memory/retrieval.ts +96 -0
  138. package/src/lib/agents/memory/types.ts +6 -0
  139. package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
  140. package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
  141. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
  142. package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
  143. package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
  144. package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
  145. package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
  146. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
  147. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
  148. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
  149. package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
  150. package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
  151. package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
  152. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
  153. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
  154. package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
  155. package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
  156. package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
  157. package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
  158. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
  159. package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
  160. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
  161. package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
  162. package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
  163. package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
  164. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
  165. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
  166. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
  167. package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
  168. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
  169. package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
  170. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
  171. package/src/lib/agents/profiles/project-profiles.ts +193 -0
  172. package/src/lib/agents/profiles/registry.ts +130 -6
  173. package/src/lib/agents/profiles/types.ts +28 -0
  174. package/src/lib/agents/router.ts +174 -2
  175. package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
  176. package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
  177. package/src/lib/agents/runtime/catalog.ts +57 -2
  178. package/src/lib/agents/runtime/claude.ts +205 -1
  179. package/src/lib/agents/runtime/index.ts +22 -0
  180. package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
  181. package/src/lib/agents/runtime/openai-direct.ts +514 -0
  182. package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
  183. package/src/lib/agents/runtime/types.ts +2 -0
  184. package/src/lib/agents/tool-permissions.ts +203 -0
  185. package/src/lib/channels/gateway.ts +321 -0
  186. package/src/lib/channels/poller.ts +268 -0
  187. package/src/lib/channels/registry.ts +90 -0
  188. package/src/lib/channels/slack-adapter.ts +188 -0
  189. package/src/lib/channels/telegram-adapter.ts +218 -0
  190. package/src/lib/channels/types.ts +75 -0
  191. package/src/lib/channels/webhook-adapter.ts +74 -0
  192. package/src/lib/chat/context-builder.ts +22 -2
  193. package/src/lib/chat/engine.ts +95 -13
  194. package/src/lib/chat/ollama-engine.ts +198 -0
  195. package/src/lib/chat/stagent-tools.ts +106 -20
  196. package/src/lib/chat/tool-catalog.ts +24 -0
  197. package/src/lib/chat/tool-registry.ts +90 -0
  198. package/src/lib/chat/tools/chat-history-tools.ts +4 -4
  199. package/src/lib/chat/tools/document-tools.ts +7 -7
  200. package/src/lib/chat/tools/handoff-tools.ts +70 -0
  201. package/src/lib/chat/tools/notification-tools.ts +4 -4
  202. package/src/lib/chat/tools/profile-tools.ts +3 -3
  203. package/src/lib/chat/tools/project-tools.ts +3 -3
  204. package/src/lib/chat/tools/schedule-tools.ts +29 -13
  205. package/src/lib/chat/tools/settings-tools.ts +2 -2
  206. package/src/lib/chat/tools/task-tools.ts +66 -11
  207. package/src/lib/chat/tools/usage-tools.ts +2 -2
  208. package/src/lib/chat/tools/workflow-tools.ts +8 -8
  209. package/src/lib/chat/types.ts +11 -5
  210. package/src/lib/constants/known-tools.ts +19 -0
  211. package/src/lib/constants/prose-styles.ts +1 -1
  212. package/src/lib/constants/settings.ts +7 -0
  213. package/src/lib/data/channel-bindings.ts +85 -0
  214. package/src/lib/data/clear.ts +22 -0
  215. package/src/lib/data/profile-test-results.ts +48 -0
  216. package/src/lib/data/seed-data/conversations.ts +196 -0
  217. package/src/lib/data/seed-data/learned-context.ts +99 -0
  218. package/src/lib/data/seed-data/notifications.ts +54 -1
  219. package/src/lib/data/seed-data/profile-test-results.ts +96 -0
  220. package/src/lib/data/seed-data/repo-imports.ts +51 -0
  221. package/src/lib/data/seed-data/views.ts +60 -0
  222. package/src/lib/data/seed.ts +51 -0
  223. package/src/lib/db/bootstrap.ts +162 -0
  224. package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
  225. package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
  226. package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
  227. package/src/lib/db/schema.ts +190 -1
  228. package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
  229. package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
  230. package/src/lib/environment/auto-scan.ts +48 -0
  231. package/src/lib/environment/data.ts +25 -0
  232. package/src/lib/environment/profile-generator.ts +40 -10
  233. package/src/lib/environment/profile-linker.ts +143 -0
  234. package/src/lib/environment/profile-rules.ts +96 -0
  235. package/src/lib/import/dedup.ts +149 -0
  236. package/src/lib/import/format-adapter.ts +631 -0
  237. package/src/lib/import/github-api.ts +219 -0
  238. package/src/lib/import/repo-scanner.ts +251 -0
  239. package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
  240. package/src/lib/schedules/active-hours.ts +120 -0
  241. package/src/lib/schedules/heartbeat-parser.ts +224 -0
  242. package/src/lib/schedules/heartbeat-prompt.ts +153 -0
  243. package/src/lib/schedules/nlp-parser.ts +357 -0
  244. package/src/lib/schedules/scheduler.ts +218 -3
  245. package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
  246. package/src/lib/settings/helpers.ts +6 -0
  247. package/src/lib/settings/routing.ts +24 -0
  248. package/src/lib/settings/runtime-setup.ts +28 -1
  249. package/src/lib/usage/ledger.ts +2 -1
  250. package/src/lib/validators/__tests__/settings.test.ts +9 -0
  251. package/src/lib/validators/profile.ts +39 -0
  252. package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
  253. package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
  254. package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
  255. package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
  256. 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
+ }