@vibescope/mcp-server 0.2.9 → 0.3.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 (95) hide show
  1. package/CHANGELOG.md +84 -84
  2. package/README.md +194 -194
  3. package/dist/api-client.d.ts +36 -0
  4. package/dist/api-client.js +34 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +30 -38
  7. package/dist/handlers/discovery.js +2 -0
  8. package/dist/handlers/session.d.ts +11 -0
  9. package/dist/handlers/session.js +101 -0
  10. package/dist/handlers/tasks.d.ts +8 -0
  11. package/dist/handlers/tasks.js +163 -3
  12. package/dist/handlers/tool-docs.js +840 -828
  13. package/dist/handlers/validation.js +49 -2
  14. package/dist/index.js +73 -73
  15. package/dist/setup.js +6 -6
  16. package/dist/templates/agent-guidelines.js +185 -185
  17. package/dist/templates/help-content.js +1622 -1544
  18. package/dist/tools.js +130 -74
  19. package/dist/utils.d.ts +15 -11
  20. package/dist/utils.js +53 -28
  21. package/docs/TOOLS.md +2407 -2053
  22. package/package.json +51 -51
  23. package/scripts/generate-docs.ts +212 -212
  24. package/scripts/version-bump.ts +203 -203
  25. package/src/api-client.test.ts +723 -723
  26. package/src/api-client.ts +2561 -2499
  27. package/src/cli.test.ts +24 -8
  28. package/src/cli.ts +204 -212
  29. package/src/handlers/__test-setup__.ts +236 -236
  30. package/src/handlers/__test-utils__.ts +87 -87
  31. package/src/handlers/blockers.test.ts +468 -468
  32. package/src/handlers/blockers.ts +163 -163
  33. package/src/handlers/bodies-of-work.test.ts +704 -704
  34. package/src/handlers/bodies-of-work.ts +526 -526
  35. package/src/handlers/connectors.test.ts +834 -834
  36. package/src/handlers/connectors.ts +229 -229
  37. package/src/handlers/cost.test.ts +462 -462
  38. package/src/handlers/cost.ts +285 -285
  39. package/src/handlers/decisions.test.ts +382 -382
  40. package/src/handlers/decisions.ts +153 -153
  41. package/src/handlers/deployment.test.ts +551 -551
  42. package/src/handlers/deployment.ts +541 -541
  43. package/src/handlers/discovery.test.ts +206 -206
  44. package/src/handlers/discovery.ts +392 -390
  45. package/src/handlers/fallback.test.ts +537 -537
  46. package/src/handlers/fallback.ts +194 -194
  47. package/src/handlers/file-checkouts.test.ts +750 -750
  48. package/src/handlers/file-checkouts.ts +185 -185
  49. package/src/handlers/findings.test.ts +633 -633
  50. package/src/handlers/findings.ts +239 -239
  51. package/src/handlers/git-issues.test.ts +631 -631
  52. package/src/handlers/git-issues.ts +136 -136
  53. package/src/handlers/ideas.test.ts +644 -644
  54. package/src/handlers/ideas.ts +207 -207
  55. package/src/handlers/index.ts +84 -84
  56. package/src/handlers/milestones.test.ts +475 -475
  57. package/src/handlers/milestones.ts +180 -180
  58. package/src/handlers/organizations.test.ts +826 -826
  59. package/src/handlers/organizations.ts +315 -315
  60. package/src/handlers/progress.test.ts +269 -269
  61. package/src/handlers/progress.ts +77 -77
  62. package/src/handlers/project.test.ts +546 -546
  63. package/src/handlers/project.ts +239 -239
  64. package/src/handlers/requests.test.ts +303 -303
  65. package/src/handlers/requests.ts +99 -99
  66. package/src/handlers/roles.test.ts +305 -305
  67. package/src/handlers/roles.ts +219 -219
  68. package/src/handlers/session.test.ts +998 -875
  69. package/src/handlers/session.ts +839 -730
  70. package/src/handlers/sprints.test.ts +732 -732
  71. package/src/handlers/sprints.ts +537 -537
  72. package/src/handlers/tasks.test.ts +931 -907
  73. package/src/handlers/tasks.ts +1121 -945
  74. package/src/handlers/tool-categories.test.ts +66 -66
  75. package/src/handlers/tool-docs.ts +1109 -1096
  76. package/src/handlers/types.test.ts +259 -259
  77. package/src/handlers/types.ts +175 -175
  78. package/src/handlers/validation.test.ts +582 -582
  79. package/src/handlers/validation.ts +164 -113
  80. package/src/index.test.ts +674 -0
  81. package/src/index.ts +792 -792
  82. package/src/setup.test.ts +233 -233
  83. package/src/setup.ts +404 -403
  84. package/src/templates/agent-guidelines.ts +210 -210
  85. package/src/templates/help-content.ts +1751 -1673
  86. package/src/token-tracking.test.ts +463 -463
  87. package/src/token-tracking.ts +166 -166
  88. package/src/tools.test.ts +416 -0
  89. package/src/tools.ts +3611 -3555
  90. package/src/utils.test.ts +785 -683
  91. package/src/utils.ts +469 -436
  92. package/src/validators.test.ts +223 -223
  93. package/src/validators.ts +249 -249
  94. package/tsconfig.json +16 -16
  95. package/vitest.config.ts +14 -14
@@ -1,730 +1,839 @@
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 os from 'os';
13
- import type { Handler, HandlerRegistry, TokenUsage } from './types.js';
14
- import { parseArgs, createEnumValidator } from '../validators.js';
15
- import { getApiClient } from '../api-client.js';
16
- import { getAgentGuidelinesTemplate, getAgentGuidelinesSummary } from '../templates/agent-guidelines.js';
17
- import { getFallbackHelpContent, getAvailableHelpTopics } from '../templates/help-content.js';
18
-
19
- // Auto-detect machine hostname for worktree tracking
20
- const MACHINE_HOSTNAME = os.hostname();
21
-
22
- const VALID_MODES = ['lite', 'full'] as const;
23
- // Model, role, and agent_type are now open-ended - any string is accepted
24
-
25
- type SessionMode = typeof VALID_MODES[number];
26
- type SessionModel = string; // Open-ended - any model name accepted
27
- type SessionRole = string; // Open-ended - any role name accepted
28
- type AgentType = string; // Open-ended - any agent type accepted
29
-
30
- // Argument schemas for type-safe parsing
31
- const startWorkSessionSchema = {
32
- project_id: { type: 'string' as const },
33
- git_url: { type: 'string' as const },
34
- mode: { type: 'string' as const, default: 'lite', validate: createEnumValidator(VALID_MODES) },
35
- model: { type: 'string' as const }, // Open-ended - any model name accepted
36
- role: { type: 'string' as const, default: 'developer' }, // Open-ended - any role name accepted
37
- hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
38
- agent_type: { type: 'string' as const }, // Open-ended - any agent type accepted
39
- };
40
-
41
- const heartbeatSchema = {
42
- session_id: { type: 'string' as const },
43
- current_worktree_path: { type: 'string' as const },
44
- hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
45
- };
46
-
47
- const endWorkSessionSchema = {
48
- session_id: { type: 'string' as const },
49
- };
50
-
51
- const getHelpSchema = {
52
- topic: { type: 'string' as const, required: true as const },
53
- };
54
-
55
- export const startWorkSession: Handler = async (args, ctx) => {
56
- const { project_id, git_url, mode, model, role, hostname: providedHostname, agent_type } = parseArgs(args, startWorkSessionSchema);
57
-
58
- // Use auto-detected hostname if not provided - enables machine-aware worktree filtering
59
- const hostname = providedHostname || MACHINE_HOSTNAME;
60
-
61
- const { session, updateSession } = ctx;
62
-
63
- // Reset token tracking for new session with model info
64
- // Model is now open-ended - use as-is (normalize Claude model names for consistency)
65
- const normalizedModel = model ? model.toLowerCase().replace(/^claude[- ]*/i, '') : null;
66
-
67
- updateSession({
68
- tokenUsage: {
69
- callCount: 0,
70
- totalTokens: 0,
71
- byTool: {},
72
- byModel: {},
73
- currentModel: normalizedModel,
74
- },
75
- });
76
-
77
- // Require project_id or git_url
78
- if (!project_id && !git_url) {
79
- return {
80
- result: {
81
- error: 'Please provide project_id or git_url to start a session',
82
- session_termination_required: true,
83
- reason: 'Cannot start work without identifying a project',
84
- action: 'END_SESSION_NOW - Do not proceed with any work until MCP is properly configured.',
85
- },
86
- };
87
- }
88
-
89
- const apiClient = getApiClient();
90
- const response = await apiClient.startSession({
91
- project_id,
92
- git_url,
93
- mode: mode as SessionMode,
94
- model: model as SessionModel | undefined,
95
- role: role as SessionRole,
96
- hostname, // Machine hostname for worktree tracking
97
- agent_type: agent_type as AgentType | undefined, // Agent type for onboarding
98
- });
99
-
100
- if (!response.ok) {
101
- // Include additional error details if available
102
- const errorData = response.data as { detail?: string; code?: string } | undefined;
103
- return {
104
- result: {
105
- error: response.error || 'Failed to start session',
106
- ...(errorData?.detail && { detail: errorData.detail }),
107
- ...(errorData?.code && { code: errorData.code }),
108
- session_termination_required: true,
109
- reason: 'MCP server connection failed - cannot track work',
110
- action: 'END_SESSION_NOW - Do not proceed with any work.',
111
- troubleshooting: [
112
- '1. Check if MCP server is configured: claude mcp list',
113
- '2. Verify VIBESCOPE_API_KEY is set correctly',
114
- '3. Check network connectivity to vibescope.dev',
115
- '4. Restart Claude Code after fixing configuration',
116
- ],
117
- user_message: 'MCP connection to Vibescope failed. I cannot proceed without task tracking. Please fix the configuration and restart.',
118
- },
119
- };
120
- }
121
-
122
- const data = response.data;
123
-
124
- // Handle project not found - include agent guidelines for new project setup
125
- if (!data?.session_started) {
126
- // If project_not_found, include agent guidelines template for CLAUDE.md setup
127
- if (data?.project_not_found) {
128
- return {
129
- result: {
130
- ...data,
131
- agent_guidelines: {
132
- message: 'IMPORTANT: After creating the project, add these guidelines to your .claude/CLAUDE.md file.',
133
- summary: getAgentGuidelinesSummary(),
134
- full_template: getAgentGuidelinesTemplate(),
135
- setup_instructions: [
136
- '1. Create the project using create_project()',
137
- '2. Create .claude/CLAUDE.md in your project root',
138
- '3. Copy the full_template content into CLAUDE.md',
139
- '4. Call start_work_session again to begin work',
140
- ],
141
- },
142
- },
143
- };
144
- }
145
- return { result: data };
146
- }
147
-
148
- // Store session ID and persona in local state
149
- if (data.session_id) {
150
- updateSession({
151
- currentSessionId: data.session_id,
152
- currentPersona: data.persona || null,
153
- });
154
- }
155
-
156
- // Check for urgent questions - these MUST be handled first
157
- const hasUrgentQuestions = data.URGENT_QUESTIONS || (data.pending_requests && data.pending_requests.length > 0);
158
-
159
- // Build result - URGENT_QUESTIONS at absolute top for maximum visibility
160
- const result: Record<string, unknown> = {
161
- session_started: true,
162
- };
163
-
164
- // URGENT_QUESTIONS must be the FIRST thing the agent sees
165
- if (data.URGENT_QUESTIONS) {
166
- result.URGENT_QUESTIONS = data.URGENT_QUESTIONS;
167
- }
168
-
169
- // Directive comes right after urgent questions
170
- result.directive = data.directive || 'ACTION_REQUIRED: Start working immediately.';
171
- result.auto_continue = true;
172
-
173
- // Session info
174
- result.session_id = data.session_id;
175
- result.persona = data.persona;
176
- result.role = data.role;
177
- result.project = data.project;
178
-
179
- // Add task data
180
- if (data.next_task) {
181
- result.next_task = data.next_task;
182
- }
183
-
184
- // Add pending requests (questions from user) - these take priority
185
- if (data.pending_requests && data.pending_requests.length > 0) {
186
- result.pending_requests = data.pending_requests;
187
- result.pending_requests_count = data.pending_requests.length;
188
- }
189
-
190
- // Add active tasks for full mode
191
- if (data.active_tasks) {
192
- result.active_tasks = data.active_tasks;
193
- }
194
-
195
- // Add blockers
196
- if (data.blockers) {
197
- result.open_blockers = data.blockers;
198
- }
199
- if (data.blockers_count !== undefined && data.blockers_count > 0) {
200
- result.blockers_count = data.blockers_count;
201
- }
202
-
203
- // Add validation tasks when present - agents should validate before starting new work
204
- if (data.validation_count !== undefined && data.validation_count > 0) {
205
- result.validation_count = data.validation_count;
206
- }
207
- if (data.awaiting_validation && data.awaiting_validation.length > 0) {
208
- result.awaiting_validation = data.awaiting_validation;
209
- result.validation_priority = data.validation_priority;
210
- }
211
-
212
- // Add stale worktrees warning if any exist
213
- if (data.stale_worktrees && data.stale_worktrees.length > 0) {
214
- result.stale_worktrees = data.stale_worktrees;
215
- result.stale_worktrees_count = data.stale_worktrees_count;
216
- result.cleanup_action = data.cleanup_action;
217
- }
218
-
219
- // Add git workflow info if available in project
220
- if (data.project?.git_workflow && data.project.git_workflow !== 'none') {
221
- // Branching workflows (git-flow, github-flow) require worktrees
222
- // Trunk-based development commits directly to main, no worktree needed
223
- const isBranchingWorkflow = data.project.git_workflow === 'git-flow' || data.project.git_workflow === 'github-flow';
224
- const baseBranch = data.project.git_workflow === 'git-flow'
225
- ? (data.project.git_develop_branch || 'develop')
226
- : (data.project.git_main_branch || 'main');
227
-
228
- result.git_workflow = {
229
- workflow: data.project.git_workflow,
230
- auto_branch: data.project.git_auto_branch ?? false,
231
- main_branch: data.project.git_main_branch || 'main',
232
- ...(data.project.git_workflow === 'git-flow' && data.project.git_develop_branch
233
- ? { develop_branch: data.project.git_develop_branch }
234
- : {}),
235
- worktree_required: isBranchingWorkflow,
236
- };
237
-
238
- // Only show worktree reminder for branching workflows (git-flow, github-flow)
239
- if (isBranchingWorkflow) {
240
- result.WORKTREE_REMINDER = {
241
- message: 'CRITICAL: Create worktree BEFORE making ANY file edits',
242
- wrong_order: 'DO NOT: Edit files stash create worktree → pop stash',
243
- right_order: 'DO: Create worktree cd into it → THEN edit files',
244
- command: `git worktree add ../<project>-<persona>-<task> -b feature/<task-id> ${baseBranch}`,
245
- help: 'Run get_help("git") for full instructions',
246
- };
247
- }
248
- }
249
-
250
- // Add agent setup instructions if this is a new agent type for the project
251
- if (data.agent_setup) {
252
- result.agent_setup = data.agent_setup;
253
- // If setup is required, update directive to prioritize setup
254
- if (data.agent_setup.setup_required) {
255
- result.directive = `SETUP REQUIRED: This is your first time connecting as a ${data.agent_setup.agent_type} agent. Follow the agent_setup instructions before starting work.`;
256
- }
257
- }
258
-
259
- // Add next action at end - pending requests take priority over validation, then regular tasks
260
- if (hasUrgentQuestions) {
261
- const firstQuestion = data.URGENT_QUESTIONS?.requests?.[0] || data.pending_requests?.[0];
262
- result.next_action = firstQuestion
263
- ? `answer_question(request_id: "${firstQuestion.id}", answer: "...")`
264
- : 'Check pending_requests and respond using answer_question(request_id, answer)';
265
- } else if (data.awaiting_validation && data.awaiting_validation.length > 0) {
266
- // Validation tasks take priority over new work - use next_action from API if available
267
- result.next_action = data.next_action || `claim_validation(task_id: "${data.awaiting_validation[0].id}")`;
268
- } else if (data.next_task) {
269
- result.next_action = `update_task(task_id: "${data.next_task.id}", status: "in_progress")`;
270
- } else if (data.project) {
271
- result.next_action = `start_fallback_activity(project_id: "${data.project.id}", activity: "code_review")`;
272
- }
273
-
274
- return { result };
275
- };
276
-
277
- export const heartbeat: Handler = async (args, ctx) => {
278
- const { session_id, current_worktree_path, hostname: providedHostname } = parseArgs(args, heartbeatSchema);
279
- const { session } = ctx;
280
- const targetSession = session_id || session.currentSessionId;
281
-
282
- // Use auto-detected hostname if not provided
283
- const hostname = providedHostname || MACHINE_HOSTNAME;
284
-
285
- if (!targetSession) {
286
- return {
287
- result: {
288
- error: 'No active session. Call start_work_session first.',
289
- },
290
- };
291
- }
292
-
293
- const apiClient = getApiClient();
294
-
295
- // Send heartbeat with optional worktree path and hostname
296
- const heartbeatResponse = await apiClient.heartbeat(targetSession, {
297
- current_worktree_path,
298
- hostname,
299
- });
300
-
301
- if (!heartbeatResponse.ok) {
302
- return {
303
- result: {
304
- error: heartbeatResponse.error || 'Failed to send heartbeat',
305
- },
306
- };
307
- }
308
-
309
- // Sync token usage to session
310
- await apiClient.syncSession(targetSession, {
311
- total_tokens: session.tokenUsage.totalTokens,
312
- token_breakdown: session.tokenUsage.byTool,
313
- model_usage: session.tokenUsage.byModel,
314
- });
315
-
316
- return {
317
- result: {
318
- success: true,
319
- session_id: targetSession,
320
- timestamp: heartbeatResponse.data?.timestamp || new Date().toISOString(),
321
- },
322
- };
323
- };
324
-
325
- export const endWorkSession: Handler = async (args, ctx) => {
326
- const { session_id } = parseArgs(args, endWorkSessionSchema);
327
- const { session, updateSession } = ctx;
328
- const targetSession = session_id || session.currentSessionId;
329
-
330
- if (!targetSession) {
331
- return {
332
- result: {
333
- success: true,
334
- message: 'No active session to end',
335
- },
336
- };
337
- }
338
-
339
- const apiClient = getApiClient();
340
-
341
- // Sync final token usage before ending
342
- await apiClient.syncSession(targetSession, {
343
- total_tokens: session.tokenUsage.totalTokens,
344
- token_breakdown: session.tokenUsage.byTool,
345
- model_usage: session.tokenUsage.byModel,
346
- });
347
-
348
- // End the session
349
- const response = await apiClient.endSession(targetSession);
350
-
351
- if (!response.ok) {
352
- return {
353
- result: {
354
- error: response.error || 'Failed to end session',
355
- },
356
- };
357
- }
358
-
359
- const endedSessionId = targetSession;
360
-
361
- // Clear local session state if this was the current session
362
- if (session.currentSessionId === targetSession) {
363
- updateSession({ currentSessionId: null });
364
- }
365
-
366
- const data = response.data;
367
-
368
- return {
369
- result: {
370
- success: true,
371
- ended_session_id: endedSessionId,
372
- session_summary: {
373
- agent_name: data?.session_summary?.agent_name || 'Agent',
374
- tasks_completed_this_session: data?.session_summary?.tasks_completed_this_session || 0,
375
- tasks_awaiting_validation: data?.session_summary?.tasks_awaiting_validation || 0,
376
- tasks_released: data?.session_summary?.tasks_released || 0,
377
- token_usage: {
378
- total_calls: session.tokenUsage.callCount,
379
- total_tokens: session.tokenUsage.totalTokens,
380
- avg_per_call: session.tokenUsage.callCount > 0
381
- ? Math.round(session.tokenUsage.totalTokens / session.tokenUsage.callCount)
382
- : 0,
383
- },
384
- },
385
- reminders: data?.reminders || ['Session ended cleanly. Good work!'],
386
- },
387
- };
388
- };
389
-
390
- export const getHelp: Handler = async (args, _ctx) => {
391
- const { topic } = parseArgs(args, getHelpSchema);
392
-
393
- const apiClient = getApiClient();
394
- const response = await apiClient.getHelpTopic(topic);
395
-
396
- // Try database content first
397
- if (response.ok && response.data?.content) {
398
- return { result: { topic, content: response.data.content } };
399
- }
400
-
401
- // Fall back to local content if database is empty or unavailable
402
- const fallback = getFallbackHelpContent(topic);
403
- if (fallback) {
404
- return { result: { topic, content: fallback.content } };
405
- }
406
-
407
- // Topic not found in either source - show available topics
408
- const available = getAvailableHelpTopics();
409
-
410
- return {
411
- result: {
412
- error: `Unknown topic: ${topic}`,
413
- available,
414
- },
415
- };
416
- };
417
-
418
- // Model pricing rates (USD per 1M tokens) by pricing tier
419
- // 'standard' = regular API rates (included in Max plans)
420
- // 'extra_usage' = overage rates when exceeding plan limits (currently same as standard)
421
- export type PricingTier = 'standard' | 'extra_usage';
422
-
423
- interface ModelPricing {
424
- input: number;
425
- output: number;
426
- description?: string;
427
- }
428
-
429
- const MODEL_PRICING: Record<PricingTier, Record<string, ModelPricing>> = {
430
- standard: {
431
- // Claude models
432
- opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5' },
433
- sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4' },
434
- haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5' },
435
- // Gemini models (as of Jan 2025)
436
- gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
437
- 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
438
- 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro' },
439
- 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash' },
440
- },
441
- extra_usage: {
442
- // Claude models - extra usage/overage rates (same as standard for now)
443
- opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5 - Extra usage' },
444
- sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4 - Extra usage' },
445
- haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5 - Extra usage' },
446
- // Gemini models - extra usage rates (same as standard for now)
447
- gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
448
- 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
449
- 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro - Extra usage' },
450
- 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash - Extra usage' },
451
- },
452
- };
453
-
454
- // Legacy accessor for backward compatibility
455
- const getModelPricing = (tier: PricingTier = 'standard') => MODEL_PRICING[tier];
456
-
457
- function calculateCost(
458
- byModel: Record<string, { input: number; output: number }>,
459
- tier: PricingTier = 'standard'
460
- ): {
461
- breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }>;
462
- total: number;
463
- pricing_tier: PricingTier;
464
- } {
465
- const breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }> = {};
466
- let total = 0;
467
- const pricingTable = getModelPricing(tier);
468
-
469
- for (const [model, tokens] of Object.entries(byModel)) {
470
- const pricing = pricingTable[model];
471
- if (pricing) {
472
- const inputCost = (tokens.input / 1_000_000) * pricing.input;
473
- const outputCost = (tokens.output / 1_000_000) * pricing.output;
474
- const modelTotal = inputCost + outputCost;
475
- breakdown[model] = {
476
- input_cost: Math.round(inputCost * 10000) / 10000,
477
- output_cost: Math.round(outputCost * 10000) / 10000,
478
- total: Math.round(modelTotal * 10000) / 10000,
479
- description: pricing.description,
480
- };
481
- total += modelTotal;
482
- }
483
- }
484
-
485
- return { breakdown, total: Math.round(total * 10000) / 10000, pricing_tier: tier };
486
- }
487
-
488
- export const getTokenUsage: Handler = async (_args, ctx) => {
489
- const { session } = ctx;
490
- const sessionTokenUsage = session.tokenUsage;
491
-
492
- const topTools = Object.entries(sessionTokenUsage.byTool)
493
- .sort(([, a], [, b]) => b.tokens - a.tokens)
494
- .slice(0, 5)
495
- .map(([tool, stats]) => ({
496
- tool,
497
- calls: stats.calls,
498
- tokens: stats.tokens,
499
- avg: Math.round(stats.tokens / stats.calls),
500
- }));
501
-
502
- // Calculate model breakdown and costs for both pricing tiers
503
- const modelBreakdown = Object.entries(sessionTokenUsage.byModel || {}).map(([model, tokens]) => ({
504
- model,
505
- input_tokens: tokens.input,
506
- output_tokens: tokens.output,
507
- total_tokens: tokens.input + tokens.output,
508
- }));
509
-
510
- const standardCost = calculateCost(sessionTokenUsage.byModel || {}, 'standard');
511
- const extraUsageCost = calculateCost(sessionTokenUsage.byModel || {}, 'extra_usage');
512
-
513
- // If no model tracking, estimate cost assuming sonnet (middle tier)
514
- const hasModelData = Object.keys(sessionTokenUsage.byModel || {}).length > 0;
515
- const estimatedCostNoModel = !hasModelData
516
- ? Math.round((sessionTokenUsage.totalTokens / 1_000_000) * getModelPricing('standard').sonnet.output * 10000) / 10000
517
- : null;
518
-
519
- // Add context clearing directive when usage is high
520
- const shouldClearContext = sessionTokenUsage.callCount > 50 || sessionTokenUsage.totalTokens > 100000;
521
-
522
- return {
523
- result: {
524
- session: {
525
- calls: sessionTokenUsage.callCount,
526
- tokens: sessionTokenUsage.totalTokens,
527
- avg_per_call: sessionTokenUsage.callCount > 0
528
- ? Math.round(sessionTokenUsage.totalTokens / sessionTokenUsage.callCount)
529
- : 0,
530
- current_model: sessionTokenUsage.currentModel,
531
- },
532
- top_tools: topTools,
533
- model_breakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined,
534
- cost: {
535
- // Standard tier (optimistic - included in Max plan)
536
- standard: {
537
- by_model: Object.keys(standardCost.breakdown).length > 0 ? standardCost.breakdown : undefined,
538
- total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
539
- pricing_tier: 'standard',
540
- },
541
- // Extra usage tier (pessimistic - overage rates)
542
- extra_usage: {
543
- by_model: Object.keys(extraUsageCost.breakdown).length > 0 ? extraUsageCost.breakdown : undefined,
544
- total_usd: extraUsageCost.total > 0 ? extraUsageCost.total : estimatedCostNoModel,
545
- pricing_tier: 'extra_usage',
546
- },
547
- // Legacy field for backward compatibility
548
- total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
549
- estimated: estimatedCostNoModel !== null,
550
- },
551
- supported_models: {
552
- claude: ['opus', 'sonnet', 'haiku'],
553
- gemini: ['gemini', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
554
- },
555
- note: 'IMPORTANT: These estimates only track MCP tool I/O (~1-5% of actual API usage). Actual costs may be 20-100x higher due to conversation context, system prompts, and reasoning tokens. Use report_token_usage() with actual API response values for accurate tracking.',
556
- estimation_coverage: 'mcp_tool_io_only',
557
- ...(shouldClearContext && {
558
- directive: 'High token usage detected. Run /clear then start_work_session to reset context. Do not ask permission.',
559
- }),
560
- },
561
- };
562
- };
563
-
564
- const reportTokenUsageSchema = {
565
- input_tokens: { type: 'number' as const, required: true as const },
566
- output_tokens: { type: 'number' as const, required: true as const },
567
- model: { type: 'string' as const }, // Open-ended - any model name accepted
568
- };
569
-
570
- const confirmAgentSetupSchema = {
571
- project_id: { type: 'string' as const, required: true as const },
572
- agent_type: { type: 'string' as const, required: true as const }, // Open-ended - any agent type accepted
573
- };
574
-
575
- /**
576
- * Report actual Claude API token usage for accurate cost tracking.
577
- * This allows agents to report their actual API usage instead of relying on MCP estimates.
578
- * The backend will attribute costs to the current task if one is active.
579
- */
580
- export const reportTokenUsage: Handler = async (args, ctx) => {
581
- const { input_tokens, output_tokens, model } = parseArgs(args, reportTokenUsageSchema);
582
- const { session, updateSession } = ctx;
583
-
584
- // Validate token counts
585
- if (input_tokens! < 0 || output_tokens! < 0) {
586
- return {
587
- result: {
588
- error: 'Token counts must be non-negative',
589
- },
590
- };
591
- }
592
-
593
- // Determine which model to attribute to
594
- const targetModel = model || session.tokenUsage.currentModel || 'sonnet';
595
-
596
- // Update the session's local token usage
597
- const updatedByModel = { ...session.tokenUsage.byModel };
598
- if (!updatedByModel[targetModel]) {
599
- updatedByModel[targetModel] = { input: 0, output: 0 };
600
- }
601
- updatedByModel[targetModel].input += input_tokens!;
602
- updatedByModel[targetModel].output += output_tokens!;
603
-
604
- const totalTokens = input_tokens! + output_tokens!;
605
-
606
- updateSession({
607
- tokenUsage: {
608
- ...session.tokenUsage,
609
- callCount: session.tokenUsage.callCount + 1,
610
- totalTokens: session.tokenUsage.totalTokens + totalTokens,
611
- byModel: updatedByModel,
612
- },
613
- });
614
-
615
- // Report to backend - this handles both session update and task cost attribution
616
- const apiClient = getApiClient();
617
- const currentSessionId = session.currentSessionId;
618
-
619
- if (!currentSessionId) {
620
- // Calculate cost locally if no session (use standard tier)
621
- const pricing = getModelPricing('standard')[targetModel];
622
- const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
623
- const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
624
-
625
- return {
626
- result: {
627
- success: true,
628
- reported: {
629
- model: targetModel,
630
- input_tokens: input_tokens!,
631
- output_tokens: output_tokens!,
632
- total_tokens: totalTokens,
633
- estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
634
- },
635
- note: 'Token usage recorded locally. Start a session to attribute costs to your project.',
636
- },
637
- };
638
- }
639
-
640
- // Call the backend to report and attribute costs
641
- const response = await apiClient.reportTokenUsage(currentSessionId, {
642
- input_tokens: input_tokens!,
643
- output_tokens: output_tokens!,
644
- model: targetModel as 'opus' | 'sonnet' | 'haiku',
645
- });
646
-
647
- if (!response.ok) {
648
- // Fall back to local calculation on error (use standard tier)
649
- const pricing = getModelPricing('standard')[targetModel];
650
- const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
651
- const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
652
-
653
- return {
654
- result: {
655
- success: true,
656
- reported: {
657
- model: targetModel,
658
- input_tokens: input_tokens!,
659
- output_tokens: output_tokens!,
660
- total_tokens: totalTokens,
661
- estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
662
- },
663
- warning: 'Backend sync failed. Token usage recorded locally only.',
664
- },
665
- };
666
- }
667
-
668
- const data = response.data!;
669
-
670
- return {
671
- result: {
672
- success: true,
673
- reported: data.reported,
674
- task_attributed: data.task_attributed,
675
- ...(data.task_id && { task_id: data.task_id }),
676
- note: data.task_attributed
677
- ? 'Token usage recorded and attributed to current task for per-task cost tracking.'
678
- : 'Token usage recorded to session. No active task to attribute costs to.',
679
- },
680
- };
681
- };
682
-
683
- /**
684
- * Confirm that agent setup is complete for a project.
685
- * This marks the agent type as onboarded, so future sessions won't receive setup instructions.
686
- */
687
- export const confirmAgentSetup: Handler = async (args, _ctx) => {
688
- const { project_id, agent_type } = parseArgs(args, confirmAgentSetupSchema);
689
-
690
- if (!project_id || !agent_type) {
691
- return {
692
- result: {
693
- error: 'project_id and agent_type are required',
694
- },
695
- };
696
- }
697
-
698
- const apiClient = getApiClient();
699
- const response = await apiClient.confirmAgentSetup(project_id, agent_type);
700
-
701
- if (!response.ok) {
702
- return {
703
- result: {
704
- error: response.error || 'Failed to confirm agent setup',
705
- },
706
- };
707
- }
708
-
709
- return {
710
- result: {
711
- success: true,
712
- project_id,
713
- agent_type,
714
- message: `Setup confirmed for ${agent_type} agent. You will no longer receive setup instructions for this project.`,
715
- },
716
- };
717
- };
718
-
719
- /**
720
- * Session handlers registry
721
- */
722
- export const sessionHandlers: HandlerRegistry = {
723
- start_work_session: startWorkSession,
724
- heartbeat: heartbeat,
725
- end_work_session: endWorkSession,
726
- get_help: getHelp,
727
- get_token_usage: getTokenUsage,
728
- report_token_usage: reportTokenUsage,
729
- confirm_agent_setup: confirmAgentSetup,
730
- };
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 os from 'os';
13
+ import type { Handler, HandlerRegistry, TokenUsage } from './types.js';
14
+ import { parseArgs, createEnumValidator } from '../validators.js';
15
+ import { getApiClient } from '../api-client.js';
16
+ import { getAgentGuidelinesTemplate, getAgentGuidelinesSummary } from '../templates/agent-guidelines.js';
17
+ import { getFallbackHelpContent, getAvailableHelpTopics } from '../templates/help-content.js';
18
+ import { normalizeGitUrl } from '../utils.js';
19
+
20
+ // Auto-detect machine hostname for worktree tracking
21
+ const MACHINE_HOSTNAME = os.hostname();
22
+
23
+ const VALID_MODES = ['lite', 'full'] as const;
24
+ // Model, role, and agent_type are now open-ended - any string is accepted
25
+
26
+ type SessionMode = typeof VALID_MODES[number];
27
+ type SessionModel = string; // Open-ended - any model name accepted
28
+ type SessionRole = string; // Open-ended - any role name accepted
29
+ type AgentType = string; // Open-ended - any agent type accepted
30
+
31
+ // Argument schemas for type-safe parsing
32
+ const startWorkSessionSchema = {
33
+ project_id: { type: 'string' as const },
34
+ git_url: { type: 'string' as const },
35
+ mode: { type: 'string' as const, default: 'lite', validate: createEnumValidator(VALID_MODES) },
36
+ model: { type: 'string' as const }, // Open-ended - any model name accepted
37
+ role: { type: 'string' as const, default: 'developer' }, // Open-ended - any role name accepted
38
+ hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
39
+ agent_type: { type: 'string' as const }, // Open-ended - any agent type accepted
40
+ };
41
+
42
+ const heartbeatSchema = {
43
+ session_id: { type: 'string' as const },
44
+ current_worktree_path: { type: 'string' as const },
45
+ hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
46
+ };
47
+
48
+ const endWorkSessionSchema = {
49
+ session_id: { type: 'string' as const },
50
+ };
51
+
52
+ const getHelpSchema = {
53
+ topic: { type: 'string' as const, required: true as const },
54
+ };
55
+
56
+ export const startWorkSession: Handler = async (args, ctx) => {
57
+ const { project_id, git_url, mode, model, role, hostname: providedHostname, agent_type } = parseArgs(args, startWorkSessionSchema);
58
+
59
+ // Use auto-detected hostname if not provided - enables machine-aware worktree filtering
60
+ const hostname = providedHostname || MACHINE_HOSTNAME;
61
+
62
+ // Normalize git_url and track if it was changed - helps agents understand URL matching
63
+ const normalizedGitUrl = git_url ? normalizeGitUrl(git_url) : null;
64
+ const gitUrlWasNormalized = git_url && normalizedGitUrl && git_url !== normalizedGitUrl;
65
+
66
+ const { session, updateSession } = ctx;
67
+
68
+ // Reset token tracking for new session with model info
69
+ // Model is now open-ended - use as-is (normalize Claude model names for consistency)
70
+ const normalizedModel = model ? model.toLowerCase().replace(/^claude[- ]*/i, '') : null;
71
+
72
+ updateSession({
73
+ tokenUsage: {
74
+ callCount: 0,
75
+ totalTokens: 0,
76
+ byTool: {},
77
+ byModel: {},
78
+ currentModel: normalizedModel,
79
+ },
80
+ });
81
+
82
+ // Require project_id or git_url
83
+ if (!project_id && !git_url) {
84
+ return {
85
+ result: {
86
+ error: 'Please provide project_id or git_url to start a session',
87
+ session_termination_required: true,
88
+ reason: 'Cannot start work without identifying a project',
89
+ action: 'END_SESSION_NOW - Do not proceed with any work until MCP is properly configured.',
90
+ },
91
+ };
92
+ }
93
+
94
+ const apiClient = getApiClient();
95
+ const response = await apiClient.startSession({
96
+ project_id,
97
+ git_url,
98
+ mode: mode as SessionMode,
99
+ model: model as SessionModel | undefined,
100
+ role: role as SessionRole,
101
+ hostname, // Machine hostname for worktree tracking
102
+ agent_type: agent_type as AgentType | undefined, // Agent type for onboarding
103
+ });
104
+
105
+ if (!response.ok) {
106
+ // Include additional error details if available
107
+ const errorData = response.data as { detail?: string; code?: string } | undefined;
108
+ return {
109
+ result: {
110
+ error: response.error || 'Failed to start session',
111
+ ...(errorData?.detail && { detail: errorData.detail }),
112
+ ...(errorData?.code && { code: errorData.code }),
113
+ session_termination_required: true,
114
+ reason: 'MCP server connection failed - cannot track work',
115
+ action: 'END_SESSION_NOW - Do not proceed with any work.',
116
+ troubleshooting: [
117
+ '1. Check if MCP server is configured: claude mcp list',
118
+ '2. Verify VIBESCOPE_API_KEY is set correctly',
119
+ '3. Check network connectivity to vibescope.dev',
120
+ '4. Restart Claude Code after fixing configuration',
121
+ ],
122
+ user_message: 'MCP connection to Vibescope failed. I cannot proceed without task tracking. Please fix the configuration and restart.',
123
+ },
124
+ };
125
+ }
126
+
127
+ const data = response.data;
128
+
129
+ // Handle project not found - include agent guidelines for new project setup
130
+ if (!data?.session_started) {
131
+ // If project_not_found, include agent guidelines template for CLAUDE.md setup
132
+ if (data?.project_not_found) {
133
+ return {
134
+ result: {
135
+ ...data,
136
+ agent_guidelines: {
137
+ message: 'IMPORTANT: After creating the project, add these guidelines to your .claude/CLAUDE.md file.',
138
+ summary: getAgentGuidelinesSummary(),
139
+ full_template: getAgentGuidelinesTemplate(),
140
+ setup_instructions: [
141
+ '1. Create the project using create_project()',
142
+ '2. Create .claude/CLAUDE.md in your project root',
143
+ '3. Copy the full_template content into CLAUDE.md',
144
+ '4. Call start_work_session again to begin work',
145
+ ],
146
+ },
147
+ },
148
+ };
149
+ }
150
+ return { result: data };
151
+ }
152
+
153
+ // Store session ID and persona in local state
154
+ if (data.session_id) {
155
+ updateSession({
156
+ currentSessionId: data.session_id,
157
+ currentPersona: data.persona || null,
158
+ });
159
+ }
160
+
161
+ // Check for urgent questions - these MUST be handled first
162
+ const hasUrgentQuestions = data.URGENT_QUESTIONS || (data.pending_requests && data.pending_requests.length > 0);
163
+
164
+ // Build result - URGENT_QUESTIONS at absolute top for maximum visibility
165
+ const result: Record<string, unknown> = {
166
+ session_started: true,
167
+ };
168
+
169
+ // URGENT_QUESTIONS must be the FIRST thing the agent sees
170
+ if (data.URGENT_QUESTIONS) {
171
+ result.URGENT_QUESTIONS = data.URGENT_QUESTIONS;
172
+ }
173
+
174
+ // Directive comes right after urgent questions
175
+ result.directive = data.directive || 'ACTION_REQUIRED: Start working immediately.';
176
+ result.auto_continue = true;
177
+
178
+ // Session info
179
+ result.session_id = data.session_id;
180
+ result.persona = data.persona;
181
+ result.role = data.role;
182
+ result.project = data.project;
183
+
184
+ // Inform agent if git_url was normalized (helps explain URL matching behavior)
185
+ if (gitUrlWasNormalized) {
186
+ result.git_url_normalized = {
187
+ message: 'Your git URL was normalized for project lookup. All URL formats for the same repository resolve to the same project.',
188
+ original: git_url,
189
+ normalized: normalizedGitUrl,
190
+ examples: [
191
+ 'git@github.com:owner/repo.git → https://github.com/owner/repo',
192
+ 'https://GITHUB.COM/Owner/Repo/ https://github.com/owner/repo',
193
+ 'http://github.com/owner/repo.git → https://github.com/owner/repo',
194
+ ],
195
+ };
196
+ }
197
+
198
+ // Add task data
199
+ if (data.next_task) {
200
+ result.next_task = data.next_task;
201
+ }
202
+
203
+ // Add pending requests (questions from user) - these take priority
204
+ if (data.pending_requests && data.pending_requests.length > 0) {
205
+ result.pending_requests = data.pending_requests;
206
+ result.pending_requests_count = data.pending_requests.length;
207
+ }
208
+
209
+ // Add active tasks for full mode
210
+ if (data.active_tasks) {
211
+ result.active_tasks = data.active_tasks;
212
+ }
213
+
214
+ // Add blockers
215
+ if (data.blockers) {
216
+ result.open_blockers = data.blockers;
217
+ }
218
+ if (data.blockers_count !== undefined && data.blockers_count > 0) {
219
+ result.blockers_count = data.blockers_count;
220
+ }
221
+
222
+ // Add validation tasks when present - agents should validate before starting new work
223
+ if (data.validation_count !== undefined && data.validation_count > 0) {
224
+ result.validation_count = data.validation_count;
225
+ }
226
+ if (data.awaiting_validation && data.awaiting_validation.length > 0) {
227
+ result.awaiting_validation = data.awaiting_validation;
228
+ result.validation_priority = data.validation_priority;
229
+ }
230
+
231
+ // Add stale worktrees warning if any exist
232
+ if (data.stale_worktrees && data.stale_worktrees.length > 0) {
233
+ result.stale_worktrees = data.stale_worktrees;
234
+ result.stale_worktrees_count = data.stale_worktrees_count;
235
+ result.cleanup_action = data.cleanup_action;
236
+ }
237
+
238
+ // Add git workflow info if available in project
239
+ if (data.project?.git_workflow && data.project.git_workflow !== 'none') {
240
+ // Branching workflows (git-flow, github-flow) require worktrees
241
+ // Trunk-based development commits directly to main, no worktree needed
242
+ const isBranchingWorkflow = data.project.git_workflow === 'git-flow' || data.project.git_workflow === 'github-flow';
243
+ const baseBranch = data.project.git_workflow === 'git-flow'
244
+ ? (data.project.git_develop_branch || 'develop')
245
+ : (data.project.git_main_branch || 'main');
246
+
247
+ result.git_workflow = {
248
+ workflow: data.project.git_workflow,
249
+ auto_branch: data.project.git_auto_branch ?? false,
250
+ main_branch: data.project.git_main_branch || 'main',
251
+ ...(data.project.git_workflow === 'git-flow' && data.project.git_develop_branch
252
+ ? { develop_branch: data.project.git_develop_branch }
253
+ : {}),
254
+ worktree_required: isBranchingWorkflow,
255
+ };
256
+
257
+ // Only show worktree reminder for branching workflows (git-flow, github-flow)
258
+ if (isBranchingWorkflow) {
259
+ result.WORKTREE_REMINDER = {
260
+ message: 'CRITICAL: Create worktree BEFORE making ANY file edits',
261
+ wrong_order: 'DO NOT: Edit files → stash → create worktree → pop stash',
262
+ right_order: 'DO: Create worktree → cd into it → THEN edit files',
263
+ command: `git worktree add ../<project>-<persona>-<task> -b feature/<task-id> ${baseBranch}`,
264
+ help: 'Run get_help("git") for full instructions',
265
+ };
266
+
267
+ // Add FIRST_TIME_CONNECTION guidance for git-flow
268
+ if (data.project.git_workflow === 'git-flow') {
269
+ result.FIRST_TIME_CONNECTION = {
270
+ workflow: 'git-flow',
271
+ steps: [
272
+ `1. git checkout ${data.project.git_develop_branch || 'develop'}`,
273
+ `2. git pull origin ${data.project.git_develop_branch || 'develop'}`,
274
+ '3. All feature branches must be created from develop',
275
+ ],
276
+ warning: 'Working from main or stale branches causes merge conflicts.',
277
+ base_branch: data.project.git_develop_branch || 'develop',
278
+ };
279
+ } else if (data.project.git_workflow === 'github-flow') {
280
+ result.FIRST_TIME_CONNECTION = {
281
+ workflow: 'github-flow',
282
+ steps: [
283
+ `1. git checkout ${data.project.git_main_branch || 'main'}`,
284
+ `2. git pull origin ${data.project.git_main_branch || 'main'}`,
285
+ '3. All feature branches must be created from main',
286
+ ],
287
+ warning: 'Working from stale branches causes merge conflicts.',
288
+ base_branch: data.project.git_main_branch || 'main',
289
+ };
290
+ }
291
+ }
292
+ }
293
+
294
+ // Add agent setup instructions if this is a new agent type for the project
295
+ if (data.agent_setup) {
296
+ result.agent_setup = data.agent_setup;
297
+ // If setup is required, update directive to prioritize setup
298
+ if (data.agent_setup.setup_required) {
299
+ result.directive = `SETUP REQUIRED: This is your first time connecting as a ${data.agent_setup.agent_type} agent. Follow the agent_setup instructions before starting work.`;
300
+ }
301
+ }
302
+
303
+ // Add next action at end - pending requests take priority over validation, then regular tasks
304
+ if (hasUrgentQuestions) {
305
+ const firstQuestion = data.URGENT_QUESTIONS?.requests?.[0] || data.pending_requests?.[0];
306
+ result.next_action = firstQuestion
307
+ ? `answer_question(request_id: "${firstQuestion.id}", answer: "...")`
308
+ : 'Check pending_requests and respond using answer_question(request_id, answer)';
309
+ } else if (data.awaiting_validation && data.awaiting_validation.length > 0) {
310
+ // Validation tasks take priority over new work - use next_action from API if available
311
+ result.next_action = data.next_action || `claim_validation(task_id: "${data.awaiting_validation[0].id}")`;
312
+ } else if (data.next_task) {
313
+ result.next_action = `update_task(task_id: "${data.next_task.id}", status: "in_progress")`;
314
+ } else if (data.project) {
315
+ result.next_action = `start_fallback_activity(project_id: "${data.project.id}", activity: "code_review")`;
316
+ }
317
+
318
+ return { result };
319
+ };
320
+
321
+ export const heartbeat: Handler = async (args, ctx) => {
322
+ const { session_id, current_worktree_path, hostname: providedHostname } = parseArgs(args, heartbeatSchema);
323
+ const { session } = ctx;
324
+ const targetSession = session_id || session.currentSessionId;
325
+
326
+ // Use auto-detected hostname if not provided
327
+ const hostname = providedHostname || MACHINE_HOSTNAME;
328
+
329
+ if (!targetSession) {
330
+ return {
331
+ result: {
332
+ error: 'No active session. Call start_work_session first.',
333
+ },
334
+ };
335
+ }
336
+
337
+ const apiClient = getApiClient();
338
+
339
+ // Send heartbeat with optional worktree path and hostname
340
+ const heartbeatResponse = await apiClient.heartbeat(targetSession, {
341
+ current_worktree_path,
342
+ hostname,
343
+ });
344
+
345
+ if (!heartbeatResponse.ok) {
346
+ return {
347
+ result: {
348
+ error: heartbeatResponse.error || 'Failed to send heartbeat',
349
+ },
350
+ };
351
+ }
352
+
353
+ // Sync token usage to session
354
+ await apiClient.syncSession(targetSession, {
355
+ total_tokens: session.tokenUsage.totalTokens,
356
+ token_breakdown: session.tokenUsage.byTool,
357
+ model_usage: session.tokenUsage.byModel,
358
+ });
359
+
360
+ return {
361
+ result: {
362
+ success: true,
363
+ session_id: targetSession,
364
+ timestamp: heartbeatResponse.data?.timestamp || new Date().toISOString(),
365
+ },
366
+ };
367
+ };
368
+
369
+ export const endWorkSession: Handler = async (args, ctx) => {
370
+ const { session_id } = parseArgs(args, endWorkSessionSchema);
371
+ const { session, updateSession } = ctx;
372
+ const targetSession = session_id || session.currentSessionId;
373
+
374
+ if (!targetSession) {
375
+ return {
376
+ result: {
377
+ success: true,
378
+ message: 'No active session to end',
379
+ },
380
+ };
381
+ }
382
+
383
+ const apiClient = getApiClient();
384
+
385
+ // Sync final token usage before ending
386
+ await apiClient.syncSession(targetSession, {
387
+ total_tokens: session.tokenUsage.totalTokens,
388
+ token_breakdown: session.tokenUsage.byTool,
389
+ model_usage: session.tokenUsage.byModel,
390
+ });
391
+
392
+ // End the session
393
+ const response = await apiClient.endSession(targetSession);
394
+
395
+ if (!response.ok) {
396
+ return {
397
+ result: {
398
+ error: response.error || 'Failed to end session',
399
+ },
400
+ };
401
+ }
402
+
403
+ const endedSessionId = targetSession;
404
+
405
+ // Clear local session state if this was the current session
406
+ if (session.currentSessionId === targetSession) {
407
+ updateSession({ currentSessionId: null });
408
+ }
409
+
410
+ const data = response.data;
411
+
412
+ return {
413
+ result: {
414
+ success: true,
415
+ ended_session_id: endedSessionId,
416
+ session_summary: {
417
+ agent_name: data?.session_summary?.agent_name || 'Agent',
418
+ tasks_completed_this_session: data?.session_summary?.tasks_completed_this_session || 0,
419
+ tasks_awaiting_validation: data?.session_summary?.tasks_awaiting_validation || 0,
420
+ tasks_released: data?.session_summary?.tasks_released || 0,
421
+ token_usage: {
422
+ total_calls: session.tokenUsage.callCount,
423
+ total_tokens: session.tokenUsage.totalTokens,
424
+ avg_per_call: session.tokenUsage.callCount > 0
425
+ ? Math.round(session.tokenUsage.totalTokens / session.tokenUsage.callCount)
426
+ : 0,
427
+ },
428
+ },
429
+ reminders: data?.reminders || ['Session ended cleanly. Good work!'],
430
+ },
431
+ };
432
+ };
433
+
434
+ export const getHelp: Handler = async (args, _ctx) => {
435
+ const { topic } = parseArgs(args, getHelpSchema);
436
+
437
+ const apiClient = getApiClient();
438
+ const response = await apiClient.getHelpTopic(topic);
439
+
440
+ // Try database content first
441
+ if (response.ok && response.data?.content) {
442
+ return { result: { topic, content: response.data.content } };
443
+ }
444
+
445
+ // Fall back to local content if database is empty or unavailable
446
+ const fallback = getFallbackHelpContent(topic);
447
+ if (fallback) {
448
+ return { result: { topic, content: fallback.content } };
449
+ }
450
+
451
+ // Topic not found in either source - show available topics
452
+ const available = getAvailableHelpTopics();
453
+
454
+ return {
455
+ result: {
456
+ error: `Unknown topic: ${topic}`,
457
+ available,
458
+ },
459
+ };
460
+ };
461
+
462
+ // Model pricing rates (USD per 1M tokens) by pricing tier
463
+ // 'standard' = regular API rates (included in Max plans)
464
+ // 'extra_usage' = overage rates when exceeding plan limits (currently same as standard)
465
+ export type PricingTier = 'standard' | 'extra_usage';
466
+
467
+ interface ModelPricing {
468
+ input: number;
469
+ output: number;
470
+ description?: string;
471
+ }
472
+
473
+ const MODEL_PRICING: Record<PricingTier, Record<string, ModelPricing>> = {
474
+ standard: {
475
+ // Claude models
476
+ opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5' },
477
+ sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4' },
478
+ haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5' },
479
+ // Gemini models (as of Jan 2025)
480
+ gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
481
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
482
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro' },
483
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash' },
484
+ },
485
+ extra_usage: {
486
+ // Claude models - extra usage/overage rates (same as standard for now)
487
+ opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5 - Extra usage' },
488
+ sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4 - Extra usage' },
489
+ haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5 - Extra usage' },
490
+ // Gemini models - extra usage rates (same as standard for now)
491
+ gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
492
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
493
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro - Extra usage' },
494
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash - Extra usage' },
495
+ },
496
+ };
497
+
498
+ // Legacy accessor for backward compatibility
499
+ const getModelPricing = (tier: PricingTier = 'standard') => MODEL_PRICING[tier];
500
+
501
+ function calculateCost(
502
+ byModel: Record<string, { input: number; output: number }>,
503
+ tier: PricingTier = 'standard'
504
+ ): {
505
+ breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }>;
506
+ total: number;
507
+ pricing_tier: PricingTier;
508
+ } {
509
+ const breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }> = {};
510
+ let total = 0;
511
+ const pricingTable = getModelPricing(tier);
512
+
513
+ for (const [model, tokens] of Object.entries(byModel)) {
514
+ const pricing = pricingTable[model];
515
+ if (pricing) {
516
+ const inputCost = (tokens.input / 1_000_000) * pricing.input;
517
+ const outputCost = (tokens.output / 1_000_000) * pricing.output;
518
+ const modelTotal = inputCost + outputCost;
519
+ breakdown[model] = {
520
+ input_cost: Math.round(inputCost * 10000) / 10000,
521
+ output_cost: Math.round(outputCost * 10000) / 10000,
522
+ total: Math.round(modelTotal * 10000) / 10000,
523
+ description: pricing.description,
524
+ };
525
+ total += modelTotal;
526
+ }
527
+ }
528
+
529
+ return { breakdown, total: Math.round(total * 10000) / 10000, pricing_tier: tier };
530
+ }
531
+
532
+ export const getTokenUsage: Handler = async (_args, ctx) => {
533
+ const { session } = ctx;
534
+ const sessionTokenUsage = session.tokenUsage;
535
+
536
+ const topTools = Object.entries(sessionTokenUsage.byTool)
537
+ .sort(([, a], [, b]) => b.tokens - a.tokens)
538
+ .slice(0, 5)
539
+ .map(([tool, stats]) => ({
540
+ tool,
541
+ calls: stats.calls,
542
+ tokens: stats.tokens,
543
+ avg: Math.round(stats.tokens / stats.calls),
544
+ }));
545
+
546
+ // Calculate model breakdown and costs for both pricing tiers
547
+ const modelBreakdown = Object.entries(sessionTokenUsage.byModel || {}).map(([model, tokens]) => ({
548
+ model,
549
+ input_tokens: tokens.input,
550
+ output_tokens: tokens.output,
551
+ total_tokens: tokens.input + tokens.output,
552
+ }));
553
+
554
+ const standardCost = calculateCost(sessionTokenUsage.byModel || {}, 'standard');
555
+ const extraUsageCost = calculateCost(sessionTokenUsage.byModel || {}, 'extra_usage');
556
+
557
+ // If no model tracking, estimate cost assuming sonnet (middle tier)
558
+ const hasModelData = Object.keys(sessionTokenUsage.byModel || {}).length > 0;
559
+ const estimatedCostNoModel = !hasModelData
560
+ ? Math.round((sessionTokenUsage.totalTokens / 1_000_000) * getModelPricing('standard').sonnet.output * 10000) / 10000
561
+ : null;
562
+
563
+ // Add context clearing directive when usage is high
564
+ const shouldClearContext = sessionTokenUsage.callCount > 50 || sessionTokenUsage.totalTokens > 100000;
565
+
566
+ return {
567
+ result: {
568
+ session: {
569
+ calls: sessionTokenUsage.callCount,
570
+ tokens: sessionTokenUsage.totalTokens,
571
+ avg_per_call: sessionTokenUsage.callCount > 0
572
+ ? Math.round(sessionTokenUsage.totalTokens / sessionTokenUsage.callCount)
573
+ : 0,
574
+ current_model: sessionTokenUsage.currentModel,
575
+ },
576
+ top_tools: topTools,
577
+ model_breakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined,
578
+ cost: {
579
+ // Standard tier (optimistic - included in Max plan)
580
+ standard: {
581
+ by_model: Object.keys(standardCost.breakdown).length > 0 ? standardCost.breakdown : undefined,
582
+ total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
583
+ pricing_tier: 'standard',
584
+ },
585
+ // Extra usage tier (pessimistic - overage rates)
586
+ extra_usage: {
587
+ by_model: Object.keys(extraUsageCost.breakdown).length > 0 ? extraUsageCost.breakdown : undefined,
588
+ total_usd: extraUsageCost.total > 0 ? extraUsageCost.total : estimatedCostNoModel,
589
+ pricing_tier: 'extra_usage',
590
+ },
591
+ // Legacy field for backward compatibility
592
+ total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
593
+ estimated: estimatedCostNoModel !== null,
594
+ },
595
+ supported_models: {
596
+ claude: ['opus', 'sonnet', 'haiku'],
597
+ gemini: ['gemini', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
598
+ },
599
+ note: 'IMPORTANT: These estimates only track MCP tool I/O (~1-5% of actual API usage). Actual costs may be 20-100x higher due to conversation context, system prompts, and reasoning tokens. Use report_token_usage() with actual API response values for accurate tracking.',
600
+ estimation_coverage: 'mcp_tool_io_only',
601
+ ...(shouldClearContext && {
602
+ directive: 'High token usage detected. Run /clear then start_work_session to reset context. Do not ask permission.',
603
+ }),
604
+ },
605
+ };
606
+ };
607
+
608
+ const reportTokenUsageSchema = {
609
+ input_tokens: { type: 'number' as const, required: true as const },
610
+ output_tokens: { type: 'number' as const, required: true as const },
611
+ model: { type: 'string' as const }, // Open-ended - any model name accepted
612
+ };
613
+
614
+ const confirmAgentSetupSchema = {
615
+ project_id: { type: 'string' as const, required: true as const },
616
+ agent_type: { type: 'string' as const, required: true as const }, // Open-ended - any agent type accepted
617
+ };
618
+
619
+ /**
620
+ * Report actual Claude API token usage for accurate cost tracking.
621
+ * This allows agents to report their actual API usage instead of relying on MCP estimates.
622
+ * The backend will attribute costs to the current task if one is active.
623
+ */
624
+ export const reportTokenUsage: Handler = async (args, ctx) => {
625
+ const { input_tokens, output_tokens, model } = parseArgs(args, reportTokenUsageSchema);
626
+ const { session, updateSession } = ctx;
627
+
628
+ // Validate token counts
629
+ if (input_tokens! < 0 || output_tokens! < 0) {
630
+ return {
631
+ result: {
632
+ error: 'Token counts must be non-negative',
633
+ },
634
+ };
635
+ }
636
+
637
+ // Determine which model to attribute to
638
+ const targetModel = model || session.tokenUsage.currentModel || 'sonnet';
639
+
640
+ // Update the session's local token usage
641
+ const updatedByModel = { ...session.tokenUsage.byModel };
642
+ if (!updatedByModel[targetModel]) {
643
+ updatedByModel[targetModel] = { input: 0, output: 0 };
644
+ }
645
+ updatedByModel[targetModel].input += input_tokens!;
646
+ updatedByModel[targetModel].output += output_tokens!;
647
+
648
+ const totalTokens = input_tokens! + output_tokens!;
649
+
650
+ updateSession({
651
+ tokenUsage: {
652
+ ...session.tokenUsage,
653
+ callCount: session.tokenUsage.callCount + 1,
654
+ totalTokens: session.tokenUsage.totalTokens + totalTokens,
655
+ byModel: updatedByModel,
656
+ },
657
+ });
658
+
659
+ // Report to backend - this handles both session update and task cost attribution
660
+ const apiClient = getApiClient();
661
+ const currentSessionId = session.currentSessionId;
662
+
663
+ if (!currentSessionId) {
664
+ // Calculate cost locally if no session (use standard tier)
665
+ const pricing = getModelPricing('standard')[targetModel];
666
+ const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
667
+ const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
668
+
669
+ return {
670
+ result: {
671
+ success: true,
672
+ reported: {
673
+ model: targetModel,
674
+ input_tokens: input_tokens!,
675
+ output_tokens: output_tokens!,
676
+ total_tokens: totalTokens,
677
+ estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
678
+ },
679
+ note: 'Token usage recorded locally. Start a session to attribute costs to your project.',
680
+ },
681
+ };
682
+ }
683
+
684
+ // Call the backend to report and attribute costs
685
+ const response = await apiClient.reportTokenUsage(currentSessionId, {
686
+ input_tokens: input_tokens!,
687
+ output_tokens: output_tokens!,
688
+ model: targetModel as 'opus' | 'sonnet' | 'haiku',
689
+ });
690
+
691
+ if (!response.ok) {
692
+ // Fall back to local calculation on error (use standard tier)
693
+ const pricing = getModelPricing('standard')[targetModel];
694
+ const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
695
+ const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
696
+
697
+ return {
698
+ result: {
699
+ success: true,
700
+ reported: {
701
+ model: targetModel,
702
+ input_tokens: input_tokens!,
703
+ output_tokens: output_tokens!,
704
+ total_tokens: totalTokens,
705
+ estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
706
+ },
707
+ warning: 'Backend sync failed. Token usage recorded locally only.',
708
+ },
709
+ };
710
+ }
711
+
712
+ const data = response.data!;
713
+
714
+ return {
715
+ result: {
716
+ success: true,
717
+ reported: data.reported,
718
+ task_attributed: data.task_attributed,
719
+ ...(data.task_id && { task_id: data.task_id }),
720
+ note: data.task_attributed
721
+ ? 'Token usage recorded and attributed to current task for per-task cost tracking.'
722
+ : 'Token usage recorded to session. No active task to attribute costs to.',
723
+ },
724
+ };
725
+ };
726
+
727
+ /**
728
+ * Confirm that agent setup is complete for a project.
729
+ * This marks the agent type as onboarded, so future sessions won't receive setup instructions.
730
+ */
731
+ export const confirmAgentSetup: Handler = async (args, _ctx) => {
732
+ const { project_id, agent_type } = parseArgs(args, confirmAgentSetupSchema);
733
+
734
+ if (!project_id || !agent_type) {
735
+ return {
736
+ result: {
737
+ error: 'project_id and agent_type are required',
738
+ },
739
+ };
740
+ }
741
+
742
+ const apiClient = getApiClient();
743
+ const response = await apiClient.confirmAgentSetup(project_id, agent_type);
744
+
745
+ if (!response.ok) {
746
+ return {
747
+ result: {
748
+ error: response.error || 'Failed to confirm agent setup',
749
+ },
750
+ };
751
+ }
752
+
753
+ return {
754
+ result: {
755
+ success: true,
756
+ project_id,
757
+ agent_type,
758
+ message: `Setup confirmed for ${agent_type} agent. You will no longer receive setup instructions for this project.`,
759
+ },
760
+ };
761
+ };
762
+
763
+ const signalIdleSchema = {
764
+ session_id: { type: 'string' as const },
765
+ };
766
+
767
+ /**
768
+ * Signal that the agent is idle (no more tasks to work on).
769
+ * This immediately updates the session status to 'idle', providing real-time
770
+ * visibility on the dashboard instead of waiting for heartbeat timeout.
771
+ *
772
+ * Call this when:
773
+ * - complete_task returns no next_task
774
+ * - get_next_task returns no tasks
775
+ * - There's genuinely no work to do
776
+ */
777
+ export const signalIdle: Handler = async (args, ctx) => {
778
+ const { session_id } = parseArgs(args, signalIdleSchema);
779
+ const { session } = ctx;
780
+ const targetSession = session_id || session.currentSessionId;
781
+
782
+ if (!targetSession) {
783
+ return {
784
+ result: {
785
+ error: 'No active session. Call start_work_session first.',
786
+ },
787
+ };
788
+ }
789
+
790
+ const apiClient = getApiClient();
791
+ const response = await apiClient.signalIdle(targetSession);
792
+
793
+ if (!response.ok) {
794
+ return {
795
+ result: {
796
+ error: response.error || 'Failed to signal idle',
797
+ },
798
+ };
799
+ }
800
+
801
+ return {
802
+ result: {
803
+ success: true,
804
+ session_id: targetSession,
805
+ status: 'idle',
806
+ message: 'Session marked as idle. Dashboard will show you as inactive until you start a new task.',
807
+ next_steps: [
808
+ 'Wait for new tasks to be added by the user',
809
+ 'Or call start_fallback_activity() to do background work',
810
+ 'Or call end_work_session() if you are done for the session',
811
+ ],
812
+ AUTONOMY_RULES: {
813
+ message: 'Agents must be autonomous. Follow these rules:',
814
+ rules: [
815
+ 'Never ask "should I continue?" → Just continue',
816
+ 'Never ask "should I clear context?" → Just clear it',
817
+ 'Never say "let me know what to do" → Use get_next_task or fallback activities',
818
+ 'When context grows large: /clear → start_work_session (don\'t ask, just do it)',
819
+ ],
820
+ preferred_activities: ['code_review', 'security_review', 'test_coverage', 'documentation_review', 'dependency_audit'],
821
+ directive: 'BE PROACTIVE: Start a fallback activity now rather than waiting for user input.',
822
+ },
823
+ },
824
+ };
825
+ };
826
+
827
+ /**
828
+ * Session handlers registry
829
+ */
830
+ export const sessionHandlers: HandlerRegistry = {
831
+ start_work_session: startWorkSession,
832
+ heartbeat: heartbeat,
833
+ end_work_session: endWorkSession,
834
+ signal_idle: signalIdle,
835
+ get_help: getHelp,
836
+ get_token_usage: getTokenUsage,
837
+ report_token_usage: reportTokenUsage,
838
+ confirm_agent_setup: confirmAgentSetup,
839
+ };