@vibescope/mcp-server 0.2.3 → 0.2.5

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