@vibescope/mcp-server 0.0.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 (170) hide show
  1. package/README.md +98 -0
  2. package/dist/cli.d.ts +34 -0
  3. package/dist/cli.js +356 -0
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +367 -0
  6. package/dist/handlers/__test-utils__.d.ts +72 -0
  7. package/dist/handlers/__test-utils__.js +176 -0
  8. package/dist/handlers/blockers.d.ts +18 -0
  9. package/dist/handlers/blockers.js +81 -0
  10. package/dist/handlers/bodies-of-work.d.ts +34 -0
  11. package/dist/handlers/bodies-of-work.js +614 -0
  12. package/dist/handlers/checkouts.d.ts +37 -0
  13. package/dist/handlers/checkouts.js +377 -0
  14. package/dist/handlers/cost.d.ts +39 -0
  15. package/dist/handlers/cost.js +247 -0
  16. package/dist/handlers/decisions.d.ts +16 -0
  17. package/dist/handlers/decisions.js +64 -0
  18. package/dist/handlers/deployment.d.ts +36 -0
  19. package/dist/handlers/deployment.js +1062 -0
  20. package/dist/handlers/discovery.d.ts +14 -0
  21. package/dist/handlers/discovery.js +870 -0
  22. package/dist/handlers/fallback.d.ts +18 -0
  23. package/dist/handlers/fallback.js +216 -0
  24. package/dist/handlers/findings.d.ts +18 -0
  25. package/dist/handlers/findings.js +110 -0
  26. package/dist/handlers/git-issues.d.ts +22 -0
  27. package/dist/handlers/git-issues.js +247 -0
  28. package/dist/handlers/ideas.d.ts +19 -0
  29. package/dist/handlers/ideas.js +188 -0
  30. package/dist/handlers/index.d.ts +29 -0
  31. package/dist/handlers/index.js +65 -0
  32. package/dist/handlers/knowledge-query.d.ts +22 -0
  33. package/dist/handlers/knowledge-query.js +253 -0
  34. package/dist/handlers/knowledge.d.ts +12 -0
  35. package/dist/handlers/knowledge.js +108 -0
  36. package/dist/handlers/milestones.d.ts +20 -0
  37. package/dist/handlers/milestones.js +179 -0
  38. package/dist/handlers/organizations.d.ts +36 -0
  39. package/dist/handlers/organizations.js +428 -0
  40. package/dist/handlers/progress.d.ts +14 -0
  41. package/dist/handlers/progress.js +149 -0
  42. package/dist/handlers/project.d.ts +20 -0
  43. package/dist/handlers/project.js +278 -0
  44. package/dist/handlers/requests.d.ts +16 -0
  45. package/dist/handlers/requests.js +131 -0
  46. package/dist/handlers/roles.d.ts +30 -0
  47. package/dist/handlers/roles.js +281 -0
  48. package/dist/handlers/session.d.ts +20 -0
  49. package/dist/handlers/session.js +791 -0
  50. package/dist/handlers/tasks.d.ts +52 -0
  51. package/dist/handlers/tasks.js +1111 -0
  52. package/dist/handlers/tasks.test.d.ts +1 -0
  53. package/dist/handlers/tasks.test.js +431 -0
  54. package/dist/handlers/types.d.ts +94 -0
  55. package/dist/handlers/types.js +1 -0
  56. package/dist/handlers/validation.d.ts +16 -0
  57. package/dist/handlers/validation.js +188 -0
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.js +2707 -0
  60. package/dist/knowledge.d.ts +6 -0
  61. package/dist/knowledge.js +121 -0
  62. package/dist/tools.d.ts +2 -0
  63. package/dist/tools.js +2498 -0
  64. package/dist/utils.d.ts +149 -0
  65. package/dist/utils.js +317 -0
  66. package/dist/utils.test.d.ts +1 -0
  67. package/dist/utils.test.js +532 -0
  68. package/dist/validators.d.ts +35 -0
  69. package/dist/validators.js +111 -0
  70. package/dist/validators.test.d.ts +1 -0
  71. package/dist/validators.test.js +176 -0
  72. package/package.json +44 -0
  73. package/src/cli.test.ts +442 -0
  74. package/src/cli.ts +439 -0
  75. package/src/handlers/__test-utils__.ts +217 -0
  76. package/src/handlers/blockers.test.ts +390 -0
  77. package/src/handlers/blockers.ts +110 -0
  78. package/src/handlers/bodies-of-work.test.ts +1276 -0
  79. package/src/handlers/bodies-of-work.ts +783 -0
  80. package/src/handlers/cost.test.ts +436 -0
  81. package/src/handlers/cost.ts +322 -0
  82. package/src/handlers/decisions.test.ts +401 -0
  83. package/src/handlers/decisions.ts +86 -0
  84. package/src/handlers/deployment.test.ts +516 -0
  85. package/src/handlers/deployment.ts +1289 -0
  86. package/src/handlers/discovery.test.ts +254 -0
  87. package/src/handlers/discovery.ts +969 -0
  88. package/src/handlers/fallback.test.ts +687 -0
  89. package/src/handlers/fallback.ts +260 -0
  90. package/src/handlers/findings.test.ts +565 -0
  91. package/src/handlers/findings.ts +153 -0
  92. package/src/handlers/ideas.test.ts +753 -0
  93. package/src/handlers/ideas.ts +247 -0
  94. package/src/handlers/index.ts +69 -0
  95. package/src/handlers/milestones.test.ts +584 -0
  96. package/src/handlers/milestones.ts +217 -0
  97. package/src/handlers/organizations.test.ts +997 -0
  98. package/src/handlers/organizations.ts +550 -0
  99. package/src/handlers/progress.test.ts +369 -0
  100. package/src/handlers/progress.ts +188 -0
  101. package/src/handlers/project.test.ts +562 -0
  102. package/src/handlers/project.ts +352 -0
  103. package/src/handlers/requests.test.ts +531 -0
  104. package/src/handlers/requests.ts +150 -0
  105. package/src/handlers/session.test.ts +459 -0
  106. package/src/handlers/session.ts +912 -0
  107. package/src/handlers/tasks.test.ts +602 -0
  108. package/src/handlers/tasks.ts +1393 -0
  109. package/src/handlers/types.ts +88 -0
  110. package/src/handlers/validation.test.ts +880 -0
  111. package/src/handlers/validation.ts +223 -0
  112. package/src/index.ts +3205 -0
  113. package/src/knowledge.ts +132 -0
  114. package/src/tmpclaude-0078-cwd +1 -0
  115. package/src/tmpclaude-0ee1-cwd +1 -0
  116. package/src/tmpclaude-2dd5-cwd +1 -0
  117. package/src/tmpclaude-344c-cwd +1 -0
  118. package/src/tmpclaude-3860-cwd +1 -0
  119. package/src/tmpclaude-4b63-cwd +1 -0
  120. package/src/tmpclaude-5c73-cwd +1 -0
  121. package/src/tmpclaude-5ee3-cwd +1 -0
  122. package/src/tmpclaude-6795-cwd +1 -0
  123. package/src/tmpclaude-709e-cwd +1 -0
  124. package/src/tmpclaude-9839-cwd +1 -0
  125. package/src/tmpclaude-d829-cwd +1 -0
  126. package/src/tmpclaude-e072-cwd +1 -0
  127. package/src/tmpclaude-f6ee-cwd +1 -0
  128. package/src/utils.test.ts +681 -0
  129. package/src/utils.ts +375 -0
  130. package/src/validators.test.ts +223 -0
  131. package/src/validators.ts +122 -0
  132. package/tmpclaude-0439-cwd +1 -0
  133. package/tmpclaude-132f-cwd +1 -0
  134. package/tmpclaude-15bb-cwd +1 -0
  135. package/tmpclaude-165a-cwd +1 -0
  136. package/tmpclaude-1ba9-cwd +1 -0
  137. package/tmpclaude-21a3-cwd +1 -0
  138. package/tmpclaude-2a38-cwd +1 -0
  139. package/tmpclaude-2adf-cwd +1 -0
  140. package/tmpclaude-2f56-cwd +1 -0
  141. package/tmpclaude-3626-cwd +1 -0
  142. package/tmpclaude-3727-cwd +1 -0
  143. package/tmpclaude-40bc-cwd +1 -0
  144. package/tmpclaude-436f-cwd +1 -0
  145. package/tmpclaude-4783-cwd +1 -0
  146. package/tmpclaude-4b6d-cwd +1 -0
  147. package/tmpclaude-4ba4-cwd +1 -0
  148. package/tmpclaude-51e6-cwd +1 -0
  149. package/tmpclaude-5ecf-cwd +1 -0
  150. package/tmpclaude-6f97-cwd +1 -0
  151. package/tmpclaude-7fb2-cwd +1 -0
  152. package/tmpclaude-825c-cwd +1 -0
  153. package/tmpclaude-8baf-cwd +1 -0
  154. package/tmpclaude-8d9f-cwd +1 -0
  155. package/tmpclaude-975c-cwd +1 -0
  156. package/tmpclaude-9983-cwd +1 -0
  157. package/tmpclaude-a045-cwd +1 -0
  158. package/tmpclaude-ac4a-cwd +1 -0
  159. package/tmpclaude-b593-cwd +1 -0
  160. package/tmpclaude-b891-cwd +1 -0
  161. package/tmpclaude-c032-cwd +1 -0
  162. package/tmpclaude-cf43-cwd +1 -0
  163. package/tmpclaude-d040-cwd +1 -0
  164. package/tmpclaude-dcdd-cwd +1 -0
  165. package/tmpclaude-dcee-cwd +1 -0
  166. package/tmpclaude-e16b-cwd +1 -0
  167. package/tmpclaude-ecd2-cwd +1 -0
  168. package/tmpclaude-f48d-cwd +1 -0
  169. package/tsconfig.json +16 -0
  170. package/vitest.config.ts +13 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * Session Handlers
3
+ *
4
+ * Handles agent session lifecycle:
5
+ * - start_work_session
6
+ * - heartbeat
7
+ * - end_work_session
8
+ * - get_help
9
+ * - get_token_usage
10
+ */
11
+
12
+ import type { SupabaseClient } from '@supabase/supabase-js';
13
+ import type { Handler, HandlerRegistry, AuthContext, TokenUsage, UserUpdates } from './types.js';
14
+ import { selectPersona, extractProjectNameFromGitUrl } from '../utils.js';
15
+ import { KNOWLEDGE_BASE } from '../knowledge.js';
16
+
17
+ /**
18
+ * Get user-created items since last sync
19
+ */
20
+ async function getUserUpdates(
21
+ supabase: SupabaseClient,
22
+ auth: AuthContext,
23
+ projectId: string,
24
+ currentSessionId: string | null
25
+ ): Promise<UserUpdates | undefined> {
26
+ let lastSyncedAt: string;
27
+
28
+ if (currentSessionId) {
29
+ const { data: session } = await supabase
30
+ .from('agent_sessions')
31
+ .select('last_synced_at')
32
+ .eq('id', currentSessionId)
33
+ .single();
34
+ lastSyncedAt = session?.last_synced_at || new Date(0).toISOString();
35
+ } else {
36
+ const { data: session } = await supabase
37
+ .from('agent_sessions')
38
+ .select('last_synced_at')
39
+ .eq('api_key_id', auth.apiKeyId)
40
+ .eq('project_id', projectId)
41
+ .single();
42
+ lastSyncedAt = session?.last_synced_at || new Date(0).toISOString();
43
+ }
44
+
45
+ const [tasksResult, blockersResult, ideasResult] = await Promise.all([
46
+ supabase
47
+ .from('tasks')
48
+ .select('id, title, created_at')
49
+ .eq('project_id', projectId)
50
+ .eq('created_by', 'user')
51
+ .gt('created_at', lastSyncedAt)
52
+ .order('created_at', { ascending: false })
53
+ .limit(5),
54
+ supabase
55
+ .from('blockers')
56
+ .select('id, description, created_at')
57
+ .eq('project_id', projectId)
58
+ .eq('created_by', 'user')
59
+ .gt('created_at', lastSyncedAt)
60
+ .order('created_at', { ascending: false })
61
+ .limit(5),
62
+ supabase
63
+ .from('ideas')
64
+ .select('id, title, created_at')
65
+ .eq('project_id', projectId)
66
+ .eq('created_by', 'user')
67
+ .gt('created_at', lastSyncedAt)
68
+ .order('created_at', { ascending: false })
69
+ .limit(5),
70
+ ]);
71
+
72
+ const tasks = tasksResult.data || [];
73
+ const blockers = blockersResult.data || [];
74
+ const ideas = ideasResult.data || [];
75
+
76
+ if (tasks.length === 0 && blockers.length === 0 && ideas.length === 0) {
77
+ return undefined;
78
+ }
79
+
80
+ return { tasks, blockers, ideas };
81
+ }
82
+
83
+ export const startWorkSession: Handler = async (args, ctx) => {
84
+ const { project_id, git_url, mode = 'lite', model, role = 'developer' } = args as {
85
+ project_id?: string;
86
+ git_url?: string;
87
+ mode?: 'lite' | 'full';
88
+ model?: 'opus' | 'sonnet' | 'haiku';
89
+ role?: 'developer' | 'validator' | 'deployer' | 'reviewer' | 'maintainer';
90
+ };
91
+
92
+ const { supabase, auth, session, updateSession } = ctx;
93
+ const INSTANCE_ID = session.instanceId;
94
+
95
+ // Reset token tracking for new session with model info
96
+ const normalizedModel = model ? model.toLowerCase().replace(/^claude[- ]*/i, '') : null;
97
+ const validModel = normalizedModel && ['opus', 'sonnet', 'haiku'].includes(normalizedModel)
98
+ ? normalizedModel
99
+ : null;
100
+
101
+ updateSession({
102
+ tokenUsage: {
103
+ callCount: 0,
104
+ totalTokens: 0,
105
+ byTool: {},
106
+ byModel: {},
107
+ currentModel: validModel,
108
+ },
109
+ });
110
+
111
+ const isLiteMode = mode === 'lite';
112
+
113
+ // Require project_id or git_url
114
+ if (!project_id && !git_url) {
115
+ return {
116
+ result: {
117
+ error: 'Please provide project_id or git_url to start a session',
118
+ },
119
+ };
120
+ }
121
+
122
+ // Find project - try owned projects first, then shared projects for org-scoped keys
123
+ let project: {
124
+ id: string;
125
+ name: string;
126
+ description: string | null;
127
+ goal: string | null;
128
+ status: string;
129
+ git_url: string | null;
130
+ agent_instructions: string | null;
131
+ tech_stack: string[] | null;
132
+ } | null = null;
133
+
134
+ // First try: user-owned projects
135
+ let query = supabase
136
+ .from('projects')
137
+ .select('id, name, description, goal, status, git_url, agent_instructions, tech_stack')
138
+ .eq('user_id', auth.userId);
139
+
140
+ if (project_id) {
141
+ query = query.eq('id', project_id);
142
+ } else if (git_url) {
143
+ query = query.eq('git_url', git_url);
144
+ }
145
+
146
+ const { data: ownedProject } = await query.single();
147
+ project = ownedProject;
148
+
149
+ // Second try: if org-scoped key and no owned project found, check shared projects
150
+ if (!project && auth.scope === 'organization' && auth.organizationId) {
151
+ // Get project IDs shared with this organization
152
+ const { data: shares } = await supabase
153
+ .from('project_shares')
154
+ .select('project_id')
155
+ .eq('organization_id', auth.organizationId);
156
+
157
+ if (shares && shares.length > 0) {
158
+ const sharedProjectIds = shares.map((s) => s.project_id);
159
+
160
+ let sharedQuery = supabase
161
+ .from('projects')
162
+ .select('id, name, description, goal, status, git_url, agent_instructions, tech_stack')
163
+ .in('id', sharedProjectIds);
164
+
165
+ if (project_id) {
166
+ sharedQuery = sharedQuery.eq('id', project_id);
167
+ } else if (git_url) {
168
+ sharedQuery = sharedQuery.eq('git_url', git_url);
169
+ }
170
+
171
+ const { data: sharedProject } = await sharedQuery.single();
172
+ project = sharedProject;
173
+ }
174
+ }
175
+
176
+ if (!project) {
177
+ const suggestedName = extractProjectNameFromGitUrl(git_url || '');
178
+
179
+ return {
180
+ result: {
181
+ session_started: false,
182
+ project_not_found: true,
183
+ message: `No project found for this repository. Would you like to create one?`,
184
+ suggestion: {
185
+ action: 'create_project',
186
+ example: `create_project(name: "${suggestedName}", git_url: "${git_url || ''}", description: "Brief description of your project", goal: "What does done look like?")`,
187
+ note: 'After creating the project, call start_work_session again to begin working.',
188
+ },
189
+ },
190
+ };
191
+ }
192
+
193
+ // Create or update agent session with instance tracking
194
+ const { data: existingSession } = await supabase
195
+ .from('agent_sessions')
196
+ .select('id, agent_name')
197
+ .eq('api_key_id', auth.apiKeyId)
198
+ .eq('project_id', project.id)
199
+ .eq('instance_id', INSTANCE_ID)
200
+ .single();
201
+
202
+ let sessionId!: string;
203
+ let assignedPersona!: string;
204
+
205
+ if (existingSession && existingSession.agent_name) {
206
+ // Reuse existing persona for this instance
207
+ assignedPersona = existingSession.agent_name;
208
+ await supabase
209
+ .from('agent_sessions')
210
+ .update({
211
+ last_synced_at: new Date().toISOString(),
212
+ status: 'active',
213
+ })
214
+ .eq('id', existingSession.id);
215
+ sessionId = existingSession.id;
216
+ } else {
217
+ // Find which personas are currently in use by active sessions
218
+ const { data: activeSessions } = await supabase
219
+ .from('agent_sessions')
220
+ .select('agent_name')
221
+ .eq('project_id', project.id)
222
+ .neq('status', 'disconnected')
223
+ .gte('last_synced_at', new Date(Date.now() - 5 * 60 * 1000).toISOString());
224
+
225
+ const usedPersonas = new Set(
226
+ (activeSessions || [])
227
+ .map((s) => s.agent_name)
228
+ .filter((name): name is string => !!name)
229
+ );
230
+
231
+ // Retry loop for persona assignment
232
+ const MAX_PERSONA_RETRIES = 5;
233
+ let personaAssigned = false;
234
+
235
+ for (let attempt = 0; attempt < MAX_PERSONA_RETRIES && !personaAssigned; attempt++) {
236
+ assignedPersona = selectPersona(usedPersonas, INSTANCE_ID);
237
+
238
+ if (existingSession) {
239
+ const { error: updateError } = await supabase
240
+ .from('agent_sessions')
241
+ .update({
242
+ last_synced_at: new Date().toISOString(),
243
+ agent_name: assignedPersona,
244
+ status: 'active',
245
+ })
246
+ .eq('id', existingSession.id);
247
+
248
+ if (updateError?.code === '23505') {
249
+ usedPersonas.add(assignedPersona);
250
+ continue;
251
+ }
252
+ sessionId = existingSession.id;
253
+ personaAssigned = true;
254
+ } else {
255
+ const { data: newSession, error: sessionError } = await supabase
256
+ .from('agent_sessions')
257
+ .insert({
258
+ api_key_id: auth.apiKeyId,
259
+ project_id: project.id,
260
+ instance_id: INSTANCE_ID,
261
+ last_synced_at: new Date().toISOString(),
262
+ agent_name: assignedPersona,
263
+ status: 'active',
264
+ })
265
+ .select('id')
266
+ .single();
267
+
268
+ if (sessionError?.code === '23505') {
269
+ usedPersonas.add(assignedPersona);
270
+ continue;
271
+ }
272
+
273
+ if (sessionError || !newSession) {
274
+ throw new Error(`Failed to create agent session: ${sessionError?.message}`);
275
+ }
276
+ sessionId = newSession.id;
277
+ personaAssigned = true;
278
+ }
279
+ }
280
+
281
+ // Fallback if all retries failed
282
+ if (!personaAssigned) {
283
+ assignedPersona = `Agent-${INSTANCE_ID.slice(0, 6)}`;
284
+ if (existingSession) {
285
+ await supabase
286
+ .from('agent_sessions')
287
+ .update({
288
+ last_synced_at: new Date().toISOString(),
289
+ agent_name: assignedPersona,
290
+ status: 'active',
291
+ })
292
+ .eq('id', existingSession.id);
293
+ sessionId = existingSession.id;
294
+ } else {
295
+ const { data: newSession, error: sessionError } = await supabase
296
+ .from('agent_sessions')
297
+ .insert({
298
+ api_key_id: auth.apiKeyId,
299
+ project_id: project.id,
300
+ instance_id: INSTANCE_ID,
301
+ last_synced_at: new Date().toISOString(),
302
+ agent_name: assignedPersona,
303
+ status: 'active',
304
+ })
305
+ .select('id')
306
+ .single();
307
+
308
+ if (sessionError || !newSession) {
309
+ throw new Error(`Failed to create agent session: ${sessionError?.message}`);
310
+ }
311
+ sessionId = newSession.id;
312
+ }
313
+ }
314
+ }
315
+
316
+ // Store session ID and persona
317
+ updateSession({
318
+ currentSessionId: sessionId,
319
+ currentPersona: assignedPersona,
320
+ });
321
+
322
+ // Log session start
323
+ await supabase.from('progress_logs').insert({
324
+ project_id: project.id,
325
+ summary: `Agent session started (${assignedPersona}) [${INSTANCE_ID.slice(0, 8)}]`,
326
+ created_by: 'agent',
327
+ created_by_session_id: sessionId,
328
+ });
329
+
330
+ // Insert initial heartbeat
331
+ await supabase.from('agent_heartbeats').insert({
332
+ session_id: sessionId,
333
+ });
334
+
335
+ // LITE MODE: Minimal fetches
336
+ if (isLiteMode) {
337
+ const [nextTaskResult, blockersCountResult, validationCountResult, deploymentResult, requestsResult, firstQuestionResult] =
338
+ await Promise.all([
339
+ supabase
340
+ .from('tasks')
341
+ .select('id, title, priority, estimated_minutes')
342
+ .eq('project_id', project.id)
343
+ .eq('status', 'pending')
344
+ .is('working_agent_session_id', null)
345
+ .order('priority', { ascending: true })
346
+ .order('created_at', { ascending: true })
347
+ .limit(1)
348
+ .maybeSingle(),
349
+ supabase
350
+ .from('blockers')
351
+ .select('id', { count: 'exact', head: true })
352
+ .eq('project_id', project.id)
353
+ .eq('status', 'open'),
354
+ supabase
355
+ .from('tasks')
356
+ .select('id', { count: 'exact', head: true })
357
+ .eq('project_id', project.id)
358
+ .eq('status', 'completed')
359
+ .is('validated_at', null),
360
+ supabase
361
+ .from('deployments')
362
+ .select('id, status, environment')
363
+ .eq('project_id', project.id)
364
+ .not('status', 'in', '("deployed","failed")')
365
+ .limit(1)
366
+ .maybeSingle(),
367
+ supabase
368
+ .from('agent_requests')
369
+ .select('id', { count: 'exact', head: true })
370
+ .eq('project_id', project.id)
371
+ .is('acknowledged_at', null),
372
+ // Fetch first unanswered question with details (not just count)
373
+ supabase
374
+ .from('agent_requests')
375
+ .select('id, message, created_at')
376
+ .eq('project_id', project.id)
377
+ .eq('request_type', 'question')
378
+ .is('answered_at', null)
379
+ .order('created_at', { ascending: true })
380
+ .limit(1)
381
+ .maybeSingle(),
382
+ ]);
383
+
384
+ const blockersCount = blockersCountResult.count || 0;
385
+ const validationCount = validationCountResult.count || 0;
386
+ const requestsCount = requestsResult.count || 0;
387
+ const firstQuestion = firstQuestionResult.data;
388
+
389
+ // Determine directive and next action FIRST
390
+ let directive: string;
391
+ let nextAction: string;
392
+
393
+ if (firstQuestion) {
394
+ directive = 'ACTION_REQUIRED: Answer this question immediately. Do NOT ask for permission.';
395
+ nextAction = `answer_question(request_id: "${firstQuestion.id}", answer: "...")`;
396
+ } else if (nextTaskResult.data) {
397
+ directive = 'ACTION_REQUIRED: Start this task immediately. Do NOT ask for permission or confirmation.';
398
+ nextAction = `update_task(task_id: "${nextTaskResult.data.id}", status: "in_progress")`;
399
+ } else {
400
+ directive = 'ACTION_REQUIRED: No pending tasks. Start a fallback activity NOW without asking.';
401
+ nextAction = 'start_fallback_activity(project_id: "...", activity: "code_review")';
402
+ }
403
+
404
+ // Build result with directive at TOP for visibility
405
+ const result: Record<string, unknown> = {
406
+ session_started: true,
407
+ directive, // FIRST - most important signal
408
+ auto_continue: true, // Explicit flag for autonomous operation
409
+ session_id: sessionId,
410
+ persona: assignedPersona,
411
+ project: { id: project.id, name: project.name, goal: project.goal },
412
+ };
413
+
414
+ // Add task or question details
415
+ if (firstQuestion) {
416
+ const now = new Date();
417
+ const waitMinutes = Math.floor((now.getTime() - new Date(firstQuestion.created_at).getTime()) / 60000);
418
+ result.first_question = {
419
+ id: firstQuestion.id,
420
+ message: firstQuestion.message,
421
+ wait_minutes: waitMinutes,
422
+ };
423
+ result.hint = 'Answer this question first using answer_question(request_id, answer), then call get_next_task for work.';
424
+ } else {
425
+ result.next_task = nextTaskResult.data;
426
+ }
427
+
428
+ if (blockersCount > 0) result.blockers_count = blockersCount;
429
+ if (validationCount > 0) result.validation_count = validationCount;
430
+ if (requestsCount > 0) result.requests_count = requestsCount;
431
+
432
+ if (deploymentResult.data) {
433
+ const actions: Record<string, string> = {
434
+ pending: 'claim_deployment_validation',
435
+ validating: 'wait',
436
+ ready: 'start_deployment',
437
+ deploying: 'wait',
438
+ };
439
+ result.deployment_priority = {
440
+ id: deploymentResult.data.id,
441
+ status: deploymentResult.data.status,
442
+ action: actions[deploymentResult.data.status] || 'check_deployment_status',
443
+ };
444
+ }
445
+
446
+ result.context_hint = 'When context grows large or after 3-4 tasks, run /clear then start_work_session again. Do not ask permission to clear context.';
447
+
448
+ // Add model tracking info
449
+ if (validModel) {
450
+ result.model_tracking = { model: validModel, status: 'active' };
451
+ } else {
452
+ result.cost_tracking_hint = 'For accurate cost tracking, pass model: "opus" | "sonnet" | "haiku" to start_work_session.';
453
+ }
454
+
455
+ // REPEAT at end - agents weight last items heavily
456
+ result.next_action = nextAction;
457
+
458
+ return { result };
459
+ }
460
+
461
+ // FULL MODE: Complete context
462
+ const [tasksResult, blockersResult, decisionsResult, progressResult, ideasResult, requestsResult, deploymentResult, findingsResult] =
463
+ await Promise.all([
464
+ supabase
465
+ .from('tasks')
466
+ .select('id, title, description, priority, status, estimated_minutes')
467
+ .eq('project_id', project.id)
468
+ .in('status', ['pending', 'in_progress'])
469
+ .order('priority', { ascending: true })
470
+ .limit(10),
471
+ supabase
472
+ .from('blockers')
473
+ .select('id, description')
474
+ .eq('project_id', project.id)
475
+ .eq('status', 'open')
476
+ .limit(5),
477
+ supabase
478
+ .from('decisions')
479
+ .select('title')
480
+ .eq('project_id', project.id)
481
+ .order('created_at', { ascending: false })
482
+ .limit(5),
483
+ supabase
484
+ .from('progress_logs')
485
+ .select('summary')
486
+ .eq('project_id', project.id)
487
+ .order('created_at', { ascending: false })
488
+ .limit(5),
489
+ supabase
490
+ .from('ideas')
491
+ .select('id, title, status')
492
+ .eq('project_id', project.id)
493
+ .order('created_at', { ascending: false })
494
+ .limit(5),
495
+ supabase
496
+ .from('agent_requests')
497
+ .select('id, request_type, message, created_at, answered_at')
498
+ .eq('project_id', project.id)
499
+ .is('acknowledged_at', null)
500
+ .or(`session_id.is.null,session_id.eq.${sessionId}`)
501
+ .order('created_at', { ascending: true })
502
+ .limit(5),
503
+ supabase
504
+ .from('deployments')
505
+ .select('id, status, environment, created_at, validation_completed_at')
506
+ .eq('project_id', project.id)
507
+ .not('status', 'in', '("deployed","failed")')
508
+ .order('created_at', { ascending: false })
509
+ .limit(1)
510
+ .single(),
511
+ supabase
512
+ .from('findings')
513
+ .select('id, title, category, severity, file_path')
514
+ .eq('project_id', project.id)
515
+ .eq('status', 'open')
516
+ .order('severity', { ascending: false })
517
+ .limit(5),
518
+ ]);
519
+
520
+ const userUpdates = await getUserUpdates(supabase, auth, project.id, sessionId);
521
+
522
+ const pendingRequests = requestsResult.data || [];
523
+ const activeDeployment = deploymentResult.data;
524
+ const activeTasks = tasksResult.data || [];
525
+
526
+ // Determine directive and next action FIRST
527
+ const unansweredQuestions = pendingRequests.filter(
528
+ (r) => r.request_type === 'question' && !r.answered_at
529
+ );
530
+ const otherRequests = pendingRequests.filter(
531
+ (r) => r.request_type !== 'question' || r.answered_at
532
+ );
533
+ const pendingTask = activeTasks.find((t) => t.status === 'pending');
534
+
535
+ let directive: string;
536
+ let nextAction: string;
537
+
538
+ if (unansweredQuestions.length > 0) {
539
+ const firstQ = unansweredQuestions[0];
540
+ directive = 'ACTION_REQUIRED: Answer this question immediately. Do NOT ask for permission.';
541
+ nextAction = `answer_question(request_id: "${firstQ.id}", answer: "...")`;
542
+ } else if (pendingTask) {
543
+ directive = 'ACTION_REQUIRED: Start the highest priority pending task immediately. Do NOT ask for permission or confirmation.';
544
+ nextAction = `update_task(task_id: "${pendingTask.id}", status: "in_progress")`;
545
+ } else {
546
+ directive = 'ACTION_REQUIRED: No pending tasks. Start a fallback activity NOW without asking.';
547
+ nextAction = `start_fallback_activity(project_id: "${project.id}", activity: "code_review")`;
548
+ }
549
+
550
+ // Build result with directive at TOP for visibility
551
+ const result: Record<string, unknown> = {
552
+ session_started: true,
553
+ directive, // FIRST - most important signal
554
+ auto_continue: true, // Explicit flag for autonomous operation
555
+ session_id: sessionId,
556
+ persona: assignedPersona,
557
+ role,
558
+ project,
559
+ active_tasks: activeTasks,
560
+ };
561
+
562
+ if (activeDeployment) {
563
+ const now = new Date();
564
+ const createdAt = new Date(activeDeployment.created_at);
565
+ const validationCompletedAt = activeDeployment.validation_completed_at
566
+ ? new Date(activeDeployment.validation_completed_at)
567
+ : null;
568
+
569
+ const relevantTimestamp = activeDeployment.status === 'ready' && validationCompletedAt
570
+ ? validationCompletedAt
571
+ : createdAt;
572
+ const elapsedMs = now.getTime() - relevantTimestamp.getTime();
573
+ const elapsedMinutes = Math.floor(elapsedMs / 60000);
574
+
575
+ const actions: Record<string, string> = {
576
+ pending: 'claim_deployment_validation',
577
+ validating: 'wait',
578
+ ready: 'start_deployment',
579
+ deploying: 'wait',
580
+ };
581
+
582
+ result.deployment_priority = {
583
+ id: activeDeployment.id,
584
+ status: activeDeployment.status,
585
+ env: activeDeployment.environment,
586
+ mins: elapsedMinutes,
587
+ action: actions[activeDeployment.status] || 'check_deployment_status',
588
+ };
589
+ }
590
+
591
+ const blockers = blockersResult.data || [];
592
+ const decisions = decisionsResult.data || [];
593
+ const progress = progressResult.data || [];
594
+ const ideas = ideasResult.data || [];
595
+
596
+ if (blockers.length > 0) result.open_blockers = blockers;
597
+ if (decisions.length > 0) result.recent_decisions = decisions;
598
+ if (progress.length > 0) result.recent_progress = progress;
599
+ if (ideas.length > 0) result.ideas = ideas;
600
+
601
+ // Add question details if there are unanswered questions
602
+ if (unansweredQuestions.length > 0) {
603
+ const now = new Date();
604
+ const firstQ = unansweredQuestions[0];
605
+ result.first_question = {
606
+ id: firstQ.id,
607
+ message: firstQ.message,
608
+ wait_minutes: Math.floor((now.getTime() - new Date(firstQ.created_at).getTime()) / 60000),
609
+ };
610
+ result.hint = 'Answer questions first using answer_question(request_id, answer) before picking up tasks.';
611
+ result.unanswered_questions = unansweredQuestions.map((q) => ({
612
+ id: q.id,
613
+ message: q.message,
614
+ wait_minutes: Math.floor((now.getTime() - new Date(q.created_at).getTime()) / 60000),
615
+ }));
616
+ }
617
+ if (otherRequests.length > 0) {
618
+ result.pending_requests = otherRequests;
619
+ }
620
+
621
+ const findings = findingsResult.data || [];
622
+ if (findings.length > 0) {
623
+ result.open_findings = findings;
624
+ }
625
+
626
+ result.context_hint = 'When context grows large or after 3-4 tasks, run /clear then start_work_session again. Do not ask permission to clear context.';
627
+
628
+ // Add model tracking info
629
+ if (validModel) {
630
+ result.model_tracking = { model: validModel, status: 'active' };
631
+ } else {
632
+ result.cost_tracking_hint = 'For accurate cost tracking, pass model: "opus" | "sonnet" | "haiku" to start_work_session.';
633
+ }
634
+
635
+ // REPEAT at end - agents weight last items heavily
636
+ result.next_action = nextAction;
637
+
638
+ return { result, user_updates: userUpdates } as { result: Record<string, unknown>; user_updates?: UserUpdates };
639
+ };
640
+
641
+ export const heartbeat: Handler = async (args, ctx) => {
642
+ const { session_id } = args as { session_id?: string };
643
+ const { supabase, session } = ctx;
644
+ const targetSession = session_id || session.currentSessionId;
645
+
646
+ if (!targetSession) {
647
+ return {
648
+ result: {
649
+ error: 'No active session. Call start_work_session first.',
650
+ },
651
+ };
652
+ }
653
+
654
+ await supabase.from('agent_heartbeats').insert({
655
+ session_id: targetSession,
656
+ });
657
+
658
+ await supabase
659
+ .from('agent_sessions')
660
+ .update({
661
+ last_synced_at: new Date().toISOString(),
662
+ status: 'active',
663
+ total_tokens: session.tokenUsage.totalTokens,
664
+ token_breakdown: session.tokenUsage.byTool,
665
+ model_usage: session.tokenUsage.byModel,
666
+ })
667
+ .eq('id', targetSession);
668
+
669
+ return {
670
+ result: {
671
+ success: true,
672
+ session_id: targetSession,
673
+ timestamp: new Date().toISOString(),
674
+ },
675
+ };
676
+ };
677
+
678
+ export const endWorkSession: Handler = async (args, ctx) => {
679
+ const { session_id } = args as { session_id?: string };
680
+ const { supabase, session, updateSession } = ctx;
681
+ const targetSession = session_id || session.currentSessionId;
682
+
683
+ if (!targetSession) {
684
+ return {
685
+ result: {
686
+ success: true,
687
+ message: 'No active session to end',
688
+ },
689
+ };
690
+ }
691
+
692
+ const { data: sessionData } = await supabase
693
+ .from('agent_sessions')
694
+ .select('project_id, agent_name, started_at')
695
+ .eq('id', targetSession)
696
+ .single();
697
+
698
+ const projectId = sessionData?.project_id;
699
+ const agentName = sessionData?.agent_name || 'Agent';
700
+ const startedAt = sessionData?.started_at;
701
+
702
+ let tasksCompletedThisSession = 0;
703
+ let tasksAwaitingValidation = 0;
704
+ let tasksBeingReleased = 0;
705
+
706
+ if (projectId) {
707
+ const { count: completedCount } = await supabase
708
+ .from('tasks')
709
+ .select('id', { count: 'exact', head: true })
710
+ .eq('project_id', projectId)
711
+ .eq('status', 'completed')
712
+ .not('validated_at', 'is', null);
713
+
714
+ const { data: awaitingTasks } = await supabase
715
+ .from('tasks')
716
+ .select('id')
717
+ .eq('project_id', projectId)
718
+ .eq('status', 'completed')
719
+ .is('validated_at', null);
720
+
721
+ tasksAwaitingValidation = awaitingTasks?.length || 0;
722
+
723
+ const { count: releasingCount } = await supabase
724
+ .from('tasks')
725
+ .select('id', { count: 'exact', head: true })
726
+ .eq('working_agent_session_id', targetSession)
727
+ .eq('status', 'in_progress');
728
+
729
+ tasksBeingReleased = releasingCount || 0;
730
+
731
+ if (startedAt) {
732
+ const { count: sessionCompleted } = await supabase
733
+ .from('tasks')
734
+ .select('id', { count: 'exact', head: true })
735
+ .eq('project_id', projectId)
736
+ .eq('status', 'completed')
737
+ .gte('completed_at', startedAt);
738
+
739
+ tasksCompletedThisSession = sessionCompleted || 0;
740
+ }
741
+ }
742
+
743
+ await supabase
744
+ .from('tasks')
745
+ .update({ working_agent_session_id: null })
746
+ .eq('working_agent_session_id', targetSession);
747
+
748
+ await supabase
749
+ .from('agent_sessions')
750
+ .update({
751
+ status: 'disconnected',
752
+ current_task_id: null,
753
+ last_synced_at: new Date().toISOString(),
754
+ total_tokens: session.tokenUsage.totalTokens,
755
+ token_breakdown: session.tokenUsage.byTool,
756
+ model_usage: session.tokenUsage.byModel,
757
+ })
758
+ .eq('id', targetSession);
759
+
760
+ const endedSessionId = targetSession;
761
+
762
+ if (session.currentSessionId === targetSession) {
763
+ updateSession({ currentSessionId: null });
764
+ }
765
+
766
+ const reminders: string[] = [];
767
+ if (tasksAwaitingValidation > 0) {
768
+ reminders.push(`${tasksAwaitingValidation} task(s) awaiting validation - consider reviewing before leaving`);
769
+ }
770
+ if (tasksBeingReleased > 0) {
771
+ reminders.push(`${tasksBeingReleased} in-progress task(s) released back to the queue`);
772
+ }
773
+
774
+ return {
775
+ result: {
776
+ success: true,
777
+ ended_session_id: endedSessionId,
778
+ session_summary: {
779
+ agent_name: agentName,
780
+ tasks_completed_this_session: tasksCompletedThisSession,
781
+ tasks_awaiting_validation: tasksAwaitingValidation,
782
+ tasks_released: tasksBeingReleased,
783
+ token_usage: {
784
+ total_calls: session.tokenUsage.callCount,
785
+ total_tokens: session.tokenUsage.totalTokens,
786
+ avg_per_call: session.tokenUsage.callCount > 0
787
+ ? Math.round(session.tokenUsage.totalTokens / session.tokenUsage.callCount)
788
+ : 0,
789
+ },
790
+ },
791
+ reminders: reminders.length > 0 ? reminders : ['Session ended cleanly. Good work!'],
792
+ },
793
+ };
794
+ };
795
+
796
+ export const getHelp: Handler = async (args, _ctx) => {
797
+ const { topic } = args as { topic: string };
798
+
799
+ const content = KNOWLEDGE_BASE[topic];
800
+ if (!content) {
801
+ return {
802
+ result: {
803
+ error: `Unknown topic: ${topic}`,
804
+ available: Object.keys(KNOWLEDGE_BASE),
805
+ },
806
+ };
807
+ }
808
+
809
+ return { result: { topic, content } };
810
+ };
811
+
812
+ // Model pricing rates (USD per 1M tokens)
813
+ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
814
+ opus: { input: 15.0, output: 75.0 },
815
+ sonnet: { input: 3.0, output: 15.0 },
816
+ haiku: { input: 0.25, output: 1.25 },
817
+ };
818
+
819
+ function calculateCost(byModel: Record<string, { input: number; output: number }>): {
820
+ breakdown: Record<string, { input_cost: number; output_cost: number; total: number }>;
821
+ total: number;
822
+ } {
823
+ const breakdown: Record<string, { input_cost: number; output_cost: number; total: number }> = {};
824
+ let total = 0;
825
+
826
+ for (const [model, tokens] of Object.entries(byModel)) {
827
+ const pricing = MODEL_PRICING[model];
828
+ if (pricing) {
829
+ const inputCost = (tokens.input / 1_000_000) * pricing.input;
830
+ const outputCost = (tokens.output / 1_000_000) * pricing.output;
831
+ const modelTotal = inputCost + outputCost;
832
+ breakdown[model] = {
833
+ input_cost: Math.round(inputCost * 10000) / 10000,
834
+ output_cost: Math.round(outputCost * 10000) / 10000,
835
+ total: Math.round(modelTotal * 10000) / 10000,
836
+ };
837
+ total += modelTotal;
838
+ }
839
+ }
840
+
841
+ return { breakdown, total: Math.round(total * 10000) / 10000 };
842
+ }
843
+
844
+ export const getTokenUsage: Handler = async (_args, ctx) => {
845
+ const { session } = ctx;
846
+ const sessionTokenUsage = session.tokenUsage;
847
+
848
+ const topTools = Object.entries(sessionTokenUsage.byTool)
849
+ .sort(([, a], [, b]) => b.tokens - a.tokens)
850
+ .slice(0, 5)
851
+ .map(([tool, stats]) => ({
852
+ tool,
853
+ calls: stats.calls,
854
+ tokens: stats.tokens,
855
+ avg: Math.round(stats.tokens / stats.calls),
856
+ }));
857
+
858
+ // Calculate model breakdown and costs
859
+ const modelBreakdown = Object.entries(sessionTokenUsage.byModel || {}).map(([model, tokens]) => ({
860
+ model,
861
+ input_tokens: tokens.input,
862
+ output_tokens: tokens.output,
863
+ total_tokens: tokens.input + tokens.output,
864
+ }));
865
+
866
+ const costData = calculateCost(sessionTokenUsage.byModel || {});
867
+
868
+ // If no model tracking, estimate cost assuming sonnet (middle tier)
869
+ const estimatedCostNoModel = Object.keys(sessionTokenUsage.byModel || {}).length === 0
870
+ ? Math.round((sessionTokenUsage.totalTokens / 1_000_000) * MODEL_PRICING.sonnet.output * 10000) / 10000
871
+ : null;
872
+
873
+ // Add context clearing directive when usage is high
874
+ const shouldClearContext = sessionTokenUsage.callCount > 50 || sessionTokenUsage.totalTokens > 100000;
875
+
876
+ return {
877
+ result: {
878
+ session: {
879
+ calls: sessionTokenUsage.callCount,
880
+ tokens: sessionTokenUsage.totalTokens,
881
+ avg_per_call: sessionTokenUsage.callCount > 0
882
+ ? Math.round(sessionTokenUsage.totalTokens / sessionTokenUsage.callCount)
883
+ : 0,
884
+ current_model: sessionTokenUsage.currentModel,
885
+ },
886
+ top_tools: topTools,
887
+ model_breakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined,
888
+ cost: {
889
+ by_model: Object.keys(costData.breakdown).length > 0 ? costData.breakdown : undefined,
890
+ total_usd: costData.total > 0 ? costData.total : estimatedCostNoModel,
891
+ estimated: estimatedCostNoModel !== null,
892
+ },
893
+ note: sessionTokenUsage.currentModel
894
+ ? `Tracking ${sessionTokenUsage.currentModel} model usage. Token estimates ~4 chars/token.`
895
+ : 'Token estimates based on response size (~4 chars/token). Set model in start_work_session for accurate costs.',
896
+ ...(shouldClearContext && {
897
+ directive: 'High token usage detected. Run /clear then start_work_session to reset context. Do not ask permission.',
898
+ }),
899
+ },
900
+ };
901
+ };
902
+
903
+ /**
904
+ * Session handlers registry
905
+ */
906
+ export const sessionHandlers: HandlerRegistry = {
907
+ start_work_session: startWorkSession,
908
+ heartbeat: heartbeat,
909
+ end_work_session: endWorkSession,
910
+ get_help: getHelp,
911
+ get_token_usage: getTokenUsage,
912
+ };