@vibescope/mcp-server 0.5.0 → 0.5.2

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 (162) hide show
  1. package/CHANGELOG.md +84 -84
  2. package/README.md +194 -194
  3. package/dist/api-client/tasks.d.ts +1 -0
  4. package/dist/cli-init.js +21 -21
  5. package/dist/cli.js +26 -26
  6. package/dist/handlers/session.js +3 -1
  7. package/dist/handlers/tasks.js +7 -1
  8. package/dist/handlers/tool-docs.js +1216 -1216
  9. package/dist/index.js +73 -73
  10. package/dist/templates/agent-guidelines.d.ts +1 -1
  11. package/dist/templates/agent-guidelines.js +205 -205
  12. package/dist/templates/help-content.js +1621 -1621
  13. package/dist/tools/bodies-of-work.js +6 -6
  14. package/dist/tools/cloud-agents.js +22 -22
  15. package/dist/tools/milestones.js +2 -2
  16. package/dist/tools/requests.js +1 -1
  17. package/dist/tools/session.js +11 -11
  18. package/dist/tools/sprints.js +9 -9
  19. package/dist/tools/tasks.js +43 -35
  20. package/dist/tools/worktrees.js +14 -14
  21. package/dist/utils.js +11 -11
  22. package/docs/TOOLS.md +2687 -2685
  23. package/package.json +53 -53
  24. package/scripts/generate-docs.ts +212 -212
  25. package/scripts/version-bump.ts +203 -203
  26. package/src/api-client/blockers.ts +86 -86
  27. package/src/api-client/bodies-of-work.ts +194 -194
  28. package/src/api-client/chat.ts +50 -50
  29. package/src/api-client/connectors.ts +152 -152
  30. package/src/api-client/cost.ts +185 -185
  31. package/src/api-client/decisions.ts +87 -87
  32. package/src/api-client/deployment.ts +313 -313
  33. package/src/api-client/discovery.ts +81 -81
  34. package/src/api-client/fallback.ts +52 -52
  35. package/src/api-client/file-checkouts.ts +115 -115
  36. package/src/api-client/findings.ts +100 -100
  37. package/src/api-client/git-issues.ts +88 -88
  38. package/src/api-client/ideas.ts +112 -112
  39. package/src/api-client/index.ts +592 -592
  40. package/src/api-client/milestones.ts +83 -83
  41. package/src/api-client/organizations.ts +185 -185
  42. package/src/api-client/progress.ts +94 -94
  43. package/src/api-client/project.ts +181 -181
  44. package/src/api-client/requests.ts +54 -54
  45. package/src/api-client/session.ts +220 -220
  46. package/src/api-client/sprints.ts +227 -227
  47. package/src/api-client/subtasks.ts +57 -57
  48. package/src/api-client/tasks.ts +451 -450
  49. package/src/api-client/types.ts +32 -32
  50. package/src/api-client/validation.ts +60 -60
  51. package/src/api-client/worktrees.ts +53 -53
  52. package/src/api-client.test.ts +847 -847
  53. package/src/api-client.ts +2728 -2728
  54. package/src/cli-init.ts +558 -558
  55. package/src/cli.test.ts +284 -284
  56. package/src/cli.ts +204 -204
  57. package/src/handlers/__test-setup__.ts +240 -240
  58. package/src/handlers/__test-utils__.ts +89 -89
  59. package/src/handlers/blockers.test.ts +468 -468
  60. package/src/handlers/blockers.ts +172 -172
  61. package/src/handlers/bodies-of-work.test.ts +704 -704
  62. package/src/handlers/bodies-of-work.ts +526 -526
  63. package/src/handlers/chat.test.ts +185 -185
  64. package/src/handlers/chat.ts +101 -101
  65. package/src/handlers/cloud-agents.test.ts +438 -438
  66. package/src/handlers/cloud-agents.ts +156 -156
  67. package/src/handlers/connectors.test.ts +834 -834
  68. package/src/handlers/connectors.ts +229 -229
  69. package/src/handlers/cost.test.ts +462 -462
  70. package/src/handlers/cost.ts +285 -285
  71. package/src/handlers/decisions.test.ts +382 -382
  72. package/src/handlers/decisions.ts +153 -153
  73. package/src/handlers/deployment.test.ts +551 -551
  74. package/src/handlers/deployment.ts +570 -570
  75. package/src/handlers/discovery.test.ts +206 -206
  76. package/src/handlers/discovery.ts +433 -433
  77. package/src/handlers/fallback.test.ts +537 -537
  78. package/src/handlers/fallback.ts +194 -194
  79. package/src/handlers/file-checkouts.test.ts +750 -750
  80. package/src/handlers/file-checkouts.ts +185 -185
  81. package/src/handlers/findings.test.ts +633 -633
  82. package/src/handlers/findings.ts +239 -239
  83. package/src/handlers/git-issues.test.ts +631 -631
  84. package/src/handlers/git-issues.ts +136 -136
  85. package/src/handlers/ideas.test.ts +644 -644
  86. package/src/handlers/ideas.ts +207 -207
  87. package/src/handlers/index.ts +93 -93
  88. package/src/handlers/milestones.test.ts +475 -475
  89. package/src/handlers/milestones.ts +180 -180
  90. package/src/handlers/organizations.test.ts +826 -826
  91. package/src/handlers/organizations.ts +315 -315
  92. package/src/handlers/progress.test.ts +269 -269
  93. package/src/handlers/progress.ts +77 -77
  94. package/src/handlers/project.test.ts +546 -546
  95. package/src/handlers/project.ts +245 -245
  96. package/src/handlers/requests.test.ts +303 -303
  97. package/src/handlers/requests.ts +99 -99
  98. package/src/handlers/roles.test.ts +305 -305
  99. package/src/handlers/roles.ts +219 -219
  100. package/src/handlers/session.test.ts +998 -998
  101. package/src/handlers/session.ts +1107 -1105
  102. package/src/handlers/sprints.test.ts +732 -732
  103. package/src/handlers/sprints.ts +537 -537
  104. package/src/handlers/tasks.test.ts +931 -931
  105. package/src/handlers/tasks.ts +1144 -1137
  106. package/src/handlers/tool-categories.test.ts +66 -66
  107. package/src/handlers/tool-docs.test.ts +511 -511
  108. package/src/handlers/tool-docs.ts +1595 -1595
  109. package/src/handlers/types.test.ts +259 -259
  110. package/src/handlers/types.ts +176 -176
  111. package/src/handlers/validation.test.ts +582 -582
  112. package/src/handlers/validation.ts +164 -164
  113. package/src/handlers/version.ts +63 -63
  114. package/src/index.test.ts +674 -674
  115. package/src/index.ts +884 -884
  116. package/src/setup.test.ts +243 -243
  117. package/src/setup.ts +410 -410
  118. package/src/templates/agent-guidelines.ts +233 -233
  119. package/src/templates/help-content.ts +1751 -1751
  120. package/src/token-tracking.test.ts +463 -463
  121. package/src/token-tracking.ts +167 -167
  122. package/src/tools/blockers.ts +122 -122
  123. package/src/tools/bodies-of-work.ts +283 -283
  124. package/src/tools/chat.ts +72 -72
  125. package/src/tools/cloud-agents.ts +101 -101
  126. package/src/tools/connectors.ts +191 -191
  127. package/src/tools/cost.ts +111 -111
  128. package/src/tools/decisions.ts +111 -111
  129. package/src/tools/deployment.ts +455 -455
  130. package/src/tools/discovery.ts +76 -76
  131. package/src/tools/fallback.ts +111 -111
  132. package/src/tools/features.ts +154 -154
  133. package/src/tools/file-checkouts.ts +145 -145
  134. package/src/tools/findings.ts +101 -101
  135. package/src/tools/git-issues.ts +130 -130
  136. package/src/tools/ideas.ts +162 -162
  137. package/src/tools/index.ts +145 -145
  138. package/src/tools/milestones.ts +118 -118
  139. package/src/tools/organizations.ts +224 -224
  140. package/src/tools/persona-templates.ts +25 -25
  141. package/src/tools/progress.ts +73 -73
  142. package/src/tools/project.ts +210 -210
  143. package/src/tools/requests.ts +68 -68
  144. package/src/tools/roles.ts +112 -112
  145. package/src/tools/session.ts +181 -181
  146. package/src/tools/sprints.ts +298 -298
  147. package/src/tools/tasks.ts +583 -575
  148. package/src/tools/tools.test.ts +222 -222
  149. package/src/tools/types.ts +9 -9
  150. package/src/tools/validation.ts +75 -75
  151. package/src/tools/version.ts +34 -34
  152. package/src/tools/worktrees.ts +66 -66
  153. package/src/tools.test.ts +416 -416
  154. package/src/utils.test.ts +1014 -1014
  155. package/src/utils.ts +586 -586
  156. package/src/validators.test.ts +223 -223
  157. package/src/validators.ts +249 -249
  158. package/src/version.ts +162 -162
  159. package/tsconfig.json +16 -16
  160. package/vitest.config.ts +14 -14
  161. package/dist/tools.d.ts +0 -2
  162. package/dist/tools.js +0 -3602
@@ -1,1105 +1,1107 @@
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 crypto from 'crypto';
14
- import type { Handler, HandlerRegistry, TokenUsage } from './types.js';
15
- import { parseArgs, createEnumValidator } from '../validators.js';
16
- import { getApiClient } from '../api-client.js';
17
- import { getAgentGuidelinesTemplate, getAgentGuidelinesSummary } from '../templates/agent-guidelines.js';
18
- import { getFallbackHelpContent, getAvailableHelpTopics } from '../templates/help-content.js';
19
- import { normalizeGitUrl } from '../utils.js';
20
- import { autoPostActivity } from './chat.js';
21
-
22
- /**
23
- * Simple hash for content change detection.
24
- */
25
- function simpleHash(content: string): string {
26
- return crypto.createHash('md5').update(content).digest('hex').slice(0, 12);
27
- }
28
-
29
- /**
30
- * Build the full CLAUDE.md content from dynamic rules + project instructions.
31
- * This is what gets persisted to .claude/CLAUDE.md by the agent.
32
- */
33
- function buildPersistContent(
34
- agentRules: string[],
35
- workflow: Record<string, unknown>,
36
- project: Record<string, unknown> | undefined,
37
- agentInstructions: string
38
- ): string {
39
- const lines: string[] = [];
40
-
41
- lines.push('<!-- hash:{{HASH}} -->');
42
- lines.push('<!-- AUTO-GENERATED by Vibescope MCP. Do not edit manually — changes will be overwritten on next start_work_session. -->');
43
- lines.push('');
44
- lines.push('# Vibescope Agent Guidelines');
45
- lines.push('');
46
-
47
- // Quick start
48
- lines.push('## Quick Start');
49
- lines.push('');
50
- lines.push('```');
51
- lines.push('start_work_session(git_url: "https://github.com/YOUR/REPO", model: "opus", agent_type: "claude", agent_name: "YOUR_NAME")');
52
- lines.push('```');
53
- lines.push('');
54
-
55
- // Mandatory rules
56
- lines.push('## MANDATORY Workflow Rules (NON-NEGOTIABLE)');
57
- lines.push('');
58
- for (const rule of agentRules) {
59
- lines.push(`- ${rule}`);
60
- }
61
- lines.push('');
62
-
63
- // Session ID reminder
64
- lines.push('### Session ID');
65
- lines.push('');
66
- lines.push('Save the `session_id` from `start_work_session` and pass it on EVERY `update_task`, `complete_task`, and `get_next_task` call. Without it, task claiming fails and your name won\'t show on completed tasks.');
67
- lines.push('');
68
-
69
- // Workflow
70
- const workflowContinuous = workflow.continuous_work as { steps?: string[]; rule?: string } | undefined;
71
- if (workflowContinuous) {
72
- lines.push('## Continuous Work Loop');
73
- lines.push('');
74
- if (workflowContinuous.steps) {
75
- for (const step of workflowContinuous.steps) {
76
- lines.push(`1. ${step}`);
77
- }
78
- }
79
- if (workflowContinuous.rule) {
80
- lines.push('');
81
- lines.push(`**${workflowContinuous.rule}**`);
82
- }
83
- lines.push('');
84
- }
85
-
86
- const workflowValidation = workflow.validation as { steps?: string[] } | undefined;
87
- if (workflowValidation?.steps) {
88
- lines.push('## Validation Workflow');
89
- lines.push('');
90
- for (const step of workflowValidation.steps) {
91
- lines.push(`1. ${step}`);
92
- }
93
- lines.push('');
94
- }
95
-
96
- // Context management
97
- lines.push('## Context Management');
98
- lines.push('');
99
- lines.push('When context grows large or responses slow:');
100
- lines.push('1. Run `/clear`');
101
- lines.push('2. Call `start_work_session(...)` again immediately');
102
- lines.push('3. Continue with `next_task` — do NOT ask permission');
103
- lines.push('');
104
-
105
- // MCP connection
106
- lines.push('## MCP Connection Required');
107
- lines.push('');
108
- lines.push('**If MCP connection fails, end session immediately.** Never work without task tracking.');
109
- lines.push('');
110
-
111
- // Project-specific instructions from template
112
- if (agentInstructions.trim()) {
113
- lines.push('## Project-Specific Instructions');
114
- lines.push('');
115
- lines.push(agentInstructions.trim());
116
- lines.push('');
117
- }
118
-
119
- // Help topics
120
- lines.push('## Help Topics');
121
- lines.push('');
122
- lines.push('`get_help(topic)` for: `getting_started`, `tasks`, `validation`, `deployment`, `git`, `blockers`, `milestones`, `fallback`, `session`, `topics`');
123
-
124
- const content = lines.join('\n');
125
- // Replace hash placeholder with actual hash
126
- const hash = simpleHash(content.replace('<!-- hash:{{HASH}} -->', ''));
127
- return content.replace('{{HASH}}', hash);
128
- }
129
-
130
- // Auto-detect machine hostname for worktree tracking
131
- const MACHINE_HOSTNAME = os.hostname();
132
-
133
- const VALID_MODES = ['lite', 'full'] as const;
134
- // Model, role, and agent_type are now open-ended - any string is accepted
135
-
136
- type SessionMode = typeof VALID_MODES[number];
137
- type SessionModel = string; // Open-ended - any model name accepted
138
- type SessionRole = string; // Open-ended - any role name accepted
139
- type AgentType = string; // Open-ended - any agent type accepted
140
-
141
- // Argument schemas for type-safe parsing
142
- const startWorkSessionSchema = {
143
- project_id: { type: 'string' as const },
144
- git_url: { type: 'string' as const },
145
- mode: { type: 'string' as const, default: 'lite', validate: createEnumValidator(VALID_MODES) },
146
- model: { type: 'string' as const }, // Open-ended - any model name accepted
147
- role: { type: 'string' as const, default: 'developer' }, // Open-ended - any role name accepted
148
- hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
149
- agent_type: { type: 'string' as const }, // Open-ended - any agent type accepted
150
- agent_name: { type: 'string' as const }, // Explicit agent name for cloud/remote agents (skips persona pool)
151
- };
152
-
153
- const heartbeatSchema = {
154
- session_id: { type: 'string' as const },
155
- current_worktree_path: { type: 'string' as const },
156
- hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
157
- };
158
-
159
- const endWorkSessionSchema = {
160
- session_id: { type: 'string' as const },
161
- };
162
-
163
- const getHelpSchema = {
164
- topic: { type: 'string' as const, required: true as const },
165
- };
166
-
167
- export const startWorkSession: Handler = async (args, ctx) => {
168
- const { project_id, git_url, mode, model, role, hostname: providedHostname, agent_type, agent_name } = parseArgs(args, startWorkSessionSchema);
169
-
170
- // Use auto-detected hostname if not provided - enables machine-aware worktree filtering
171
- const hostname = providedHostname || MACHINE_HOSTNAME;
172
-
173
- // Normalize git_url and track if it was changed - helps agents understand URL matching
174
- const normalizedGitUrl = git_url ? normalizeGitUrl(git_url) : null;
175
- const gitUrlWasNormalized = git_url && normalizedGitUrl && git_url !== normalizedGitUrl;
176
-
177
- const { session, updateSession } = ctx;
178
-
179
- // Reset token tracking for new session with model info
180
- // Model is now open-ended - use as-is (normalize Claude model names for consistency)
181
- const normalizedModel = model ? model.toLowerCase().replace(/^claude[- ]*/i, '') : null;
182
-
183
- updateSession({
184
- tokenUsage: {
185
- callCount: 0,
186
- totalTokens: 0,
187
- byTool: {},
188
- byModel: {},
189
- currentModel: normalizedModel,
190
- },
191
- });
192
-
193
- // Require project_id or git_url
194
- if (!project_id && !git_url) {
195
- return {
196
- result: {
197
- error: 'Please provide project_id or git_url to start a session',
198
- session_termination_required: true,
199
- reason: 'Cannot start work without identifying a project',
200
- action: 'END_SESSION_NOW - Do not proceed with any work until MCP is properly configured.',
201
- },
202
- };
203
- }
204
-
205
- const apiClient = getApiClient();
206
- const response = await apiClient.startSession({
207
- project_id,
208
- git_url,
209
- mode: mode as SessionMode,
210
- model: model as SessionModel | undefined,
211
- role: role as SessionRole,
212
- hostname, // Machine hostname for worktree tracking
213
- agent_type: agent_type as AgentType | undefined, // Agent type for onboarding
214
- agent_name: agent_name as string | undefined, // Explicit name for cloud/remote agents
215
- });
216
-
217
- if (!response.ok) {
218
- // Include additional error details if available
219
- const errorData = response.data as { detail?: string; code?: string } | undefined;
220
- return {
221
- result: {
222
- error: response.error || 'Failed to start session',
223
- ...(errorData?.detail && { detail: errorData.detail }),
224
- ...(errorData?.code && { code: errorData.code }),
225
- session_termination_required: true,
226
- reason: 'MCP server connection failed - cannot track work',
227
- action: 'END_SESSION_NOW - Do not proceed with any work.',
228
- troubleshooting: [
229
- '1. Check if MCP server is configured: claude mcp list',
230
- '2. Verify VIBESCOPE_API_KEY is set correctly',
231
- '3. Check network connectivity to vibescope.dev',
232
- '4. Restart Claude Code after fixing configuration',
233
- ],
234
- user_message: 'MCP connection to Vibescope failed. I cannot proceed without task tracking. Please fix the configuration and restart.',
235
- },
236
- };
237
- }
238
-
239
- const data = response.data;
240
-
241
- // Handle project not found - include agent guidelines for new project setup
242
- if (!data?.session_started) {
243
- // If project_not_found, include agent guidelines template for CLAUDE.md setup
244
- if (data?.project_not_found) {
245
- return {
246
- result: {
247
- ...data,
248
- agent_guidelines: {
249
- message: 'IMPORTANT: After creating the project, add these guidelines to your .claude/CLAUDE.md file.',
250
- summary: getAgentGuidelinesSummary(),
251
- full_template: getAgentGuidelinesTemplate(),
252
- setup_instructions: [
253
- '1. Create the project using create_project()',
254
- '2. Create .claude/CLAUDE.md in your project root',
255
- '3. Copy the full_template content into CLAUDE.md',
256
- '4. Call start_work_session again to begin work',
257
- ],
258
- },
259
- },
260
- };
261
- }
262
- return { result: data };
263
- }
264
-
265
- // Store session ID and persona in local state
266
- if (data.session_id) {
267
- updateSession({
268
- currentSessionId: data.session_id,
269
- currentPersona: data.persona || null,
270
- });
271
- }
272
-
273
- // Check for urgent questions - these MUST be handled first
274
- const hasUrgentQuestions = data.URGENT_QUESTIONS || (data.pending_requests && data.pending_requests.length > 0);
275
-
276
- // Build result - URGENT_QUESTIONS at absolute top for maximum visibility
277
- const result: Record<string, unknown> = {
278
- session_started: true,
279
- };
280
-
281
- // URGENT_QUESTIONS must be the FIRST thing the agent sees
282
- if (data.URGENT_QUESTIONS) {
283
- result.URGENT_QUESTIONS = data.URGENT_QUESTIONS;
284
- }
285
-
286
- // Directive comes right after urgent questions
287
- result.directive = data.directive || 'ACTION_REQUIRED: Start working immediately.';
288
- result.auto_continue = true;
289
-
290
- // Session info
291
- result.session_id = data.session_id;
292
- result.IMPORTANT_session_id_reminder = `Save this session_id ("${data.session_id}") and pass it on EVERY update_task and complete_task call. Without it, the dashboard shows "Agent" instead of your name.`;
293
- result.persona = data.persona;
294
- result.role = data.role;
295
- result.project = data.project;
296
-
297
- // For cloud agents: include workspace setup instructions if repo not yet cloned
298
- // Cloud agents pass git_url; check if workspace needs setup
299
- if (git_url && data.project?.git_url) {
300
- result.workspace_setup = {
301
- message: 'If the project repo is NOT already cloned to your workspace, follow these steps:',
302
- steps: [
303
- `git clone ${data.project.git_url} ~/workspace/project`,
304
- 'cd ~/workspace/project',
305
- ...(data.project.git_workflow === 'git-flow'
306
- ? [`git checkout ${data.project.git_develop_branch || 'develop'}`]
307
- : []),
308
- 'Install dependencies (check for pnpm-lock.yaml, package-lock.json, etc.)',
309
- 'If using SvelteKit: run `pnpm exec svelte-kit sync` or equivalent',
310
- ],
311
- note: 'Skip these steps if ~/workspace/project already exists and has the repo.',
312
- };
313
- }
314
-
315
- // Inform agent if git_url was normalized (helps explain URL matching behavior)
316
- if (gitUrlWasNormalized) {
317
- result.git_url_normalized = {
318
- message: 'Your git URL was normalized for project lookup. All URL formats for the same repository resolve to the same project.',
319
- original: git_url,
320
- normalized: normalizedGitUrl,
321
- examples: [
322
- 'git@github.com:owner/repo.git → https://github.com/owner/repo',
323
- 'https://GITHUB.COM/Owner/Repo/ → https://github.com/owner/repo',
324
- 'http://github.com/owner/repo.git → https://github.com/owner/repo',
325
- ],
326
- };
327
- }
328
-
329
- // Add task data
330
- if (data.next_task) {
331
- result.next_task = data.next_task;
332
- }
333
-
334
- // Add pending requests (questions from user) - these take priority
335
- if (data.pending_requests && data.pending_requests.length > 0) {
336
- result.pending_requests = data.pending_requests;
337
- result.pending_requests_count = data.pending_requests.length;
338
- }
339
-
340
- // Add active tasks for full mode
341
- if (data.active_tasks) {
342
- result.active_tasks = data.active_tasks;
343
- }
344
-
345
- // Add blockers
346
- if (data.blockers) {
347
- result.open_blockers = data.blockers;
348
- }
349
- if (data.blockers_count !== undefined && data.blockers_count > 0) {
350
- result.blockers_count = data.blockers_count;
351
- }
352
-
353
- // Add validation tasks when present - agents should validate before starting new work
354
- if (data.validation_count !== undefined && data.validation_count > 0) {
355
- result.validation_count = data.validation_count;
356
- }
357
- if (data.awaiting_validation && data.awaiting_validation.length > 0) {
358
- result.awaiting_validation = data.awaiting_validation;
359
- result.validation_priority = data.validation_priority;
360
- }
361
-
362
- // Add stale worktrees warning if any exist
363
- if (data.stale_worktrees && data.stale_worktrees.length > 0) {
364
- result.stale_worktrees = data.stale_worktrees;
365
- result.stale_worktrees_count = data.stale_worktrees_count;
366
- result.cleanup_action = data.cleanup_action;
367
- }
368
-
369
- // Add git workflow info if available in project
370
- if (data.project?.git_workflow && data.project.git_workflow !== 'none') {
371
- // Branching workflows (git-flow, github-flow) require worktrees
372
- // Trunk-based development commits directly to main, no worktree needed
373
- const isBranchingWorkflow = data.project.git_workflow === 'git-flow' || data.project.git_workflow === 'github-flow';
374
- const baseBranch = data.project.git_workflow === 'git-flow'
375
- ? (data.project.git_develop_branch || 'develop')
376
- : (data.project.git_main_branch || 'main');
377
-
378
- result.git_workflow = {
379
- workflow: data.project.git_workflow,
380
- auto_branch: data.project.git_auto_branch ?? false,
381
- main_branch: data.project.git_main_branch || 'main',
382
- ...(data.project.git_workflow === 'git-flow' && data.project.git_develop_branch
383
- ? { develop_branch: data.project.git_develop_branch }
384
- : {}),
385
- worktree_required: isBranchingWorkflow,
386
- };
387
-
388
- // Only show worktree reminder for branching workflows (git-flow, github-flow)
389
- if (isBranchingWorkflow) {
390
- result.WORKTREE_REMINDER = {
391
- message: 'CRITICAL: Create worktree BEFORE making ANY file edits',
392
- wrong_order: 'DO NOT: Edit files → stash → create worktree → pop stash',
393
- right_order: 'DO: Create worktree → cd into it → THEN edit files',
394
- command: `git worktree add ../<project>-<persona>-<task> -b feature/<task-id> ${baseBranch}`,
395
- help: 'Run get_help("git") for full instructions',
396
- };
397
-
398
- // Add FIRST_TIME_CONNECTION guidance for git-flow
399
- if (data.project.git_workflow === 'git-flow') {
400
- result.FIRST_TIME_CONNECTION = {
401
- workflow: 'git-flow',
402
- steps: [
403
- `1. git checkout ${data.project.git_develop_branch || 'develop'}`,
404
- `2. git pull origin ${data.project.git_develop_branch || 'develop'}`,
405
- '3. All feature branches must be created from develop',
406
- ],
407
- warning: 'Working from main or stale branches causes merge conflicts.',
408
- base_branch: data.project.git_develop_branch || 'develop',
409
- };
410
- } else if (data.project.git_workflow === 'github-flow') {
411
- result.FIRST_TIME_CONNECTION = {
412
- workflow: 'github-flow',
413
- steps: [
414
- `1. git checkout ${data.project.git_main_branch || 'main'}`,
415
- `2. git pull origin ${data.project.git_main_branch || 'main'}`,
416
- '3. All feature branches must be created from main',
417
- ],
418
- warning: 'Working from stale branches causes merge conflicts.',
419
- base_branch: data.project.git_main_branch || 'main',
420
- };
421
- }
422
- }
423
- }
424
-
425
- // Add agent setup instructions if this is a new agent type for the project
426
- if (data.agent_setup) {
427
- result.agent_setup = data.agent_setup;
428
- // If setup is required, update directive to prioritize setup
429
- if (data.agent_setup.setup_required) {
430
- 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.`;
431
- }
432
- }
433
-
434
- // Build dynamic AGENT_RULES from project settings
435
- const agentRules: string[] = [];
436
- const projectWorkflow = data.project?.git_workflow;
437
- const isBranching = projectWorkflow === 'git-flow' || projectWorkflow === 'github-flow';
438
- const ruleBaseBranch = projectWorkflow === 'git-flow'
439
- ? (data.project?.git_develop_branch || 'develop')
440
- : (data.project?.git_main_branch || 'main');
441
-
442
- // Git workflow rules (dynamic based on project settings)
443
- if (isBranching) {
444
- agentRules.push(`WORKTREE REQUIRED: Create a git worktree BEFORE making ANY file edits. Command: git worktree add ../PROJECT-PERSONA-task -b feature/TASKID-desc ${ruleBaseBranch}`);
445
- agentRules.push(`REBASE BEFORE PR: Always rebase onto ${ruleBaseBranch} before creating a PR. Run: git fetch origin && git rebase origin/${ruleBaseBranch} && git push --force-with-lease. This prevents overwriting other agents' work.`);
446
- }
447
- if (projectWorkflow && projectWorkflow !== 'none') {
448
- agentRules.push(`GIT WORKFLOW: This project uses ${projectWorkflow}. Branch from ${ruleBaseBranch}. Do NOT commit directly to main or develop.`);
449
- }
450
-
451
- // Task lifecycle rules
452
- agentRules.push('COMPLETE TASKS: Always call complete_task(task_id, summary, session_id) immediately after creating a PR. This is mandatory — it updates the dashboard and triggers validation.');
453
- agentRules.push('SESSION ID: Pass session_id on EVERY update_task and complete_task call. Without it, the dashboard shows "Agent" instead of your name.');
454
- agentRules.push(`STATUS UPDATES: Call update_agent_status(status_message: "Working on: TASK_TITLE", project_id: "${data.project?.id || ''}", agent_name: "${result.persona || ''}") whenever you start or finish a task.`);
455
- agentRules.push('TRACK PROGRESS: Call update_task with progress_percentage (0-100) every 15-20% of task completion.');
456
-
457
- // Validation rules (dynamic based on project settings)
458
- if (data.project?.validation_required !== false) {
459
- agentRules.push('REVIEW REQUIRED: All completed tasks must be reviewed/validated by another agent before merging.');
460
- }
461
- if (data.project?.auto_merge_on_approval !== false) {
462
- agentRules.push('AUTO-MERGE: After approving a PR during validation, merge it immediately with `gh pr merge --squash`.');
463
- }
464
-
465
- // Autonomy rules
466
- agentRules.push('BE AUTONOMOUS: Pick up tasks without asking permission. Never ask "Should I continue?" — just continue to the next task.');
467
- agentRules.push('BREAK DOWN COMPLEX TASKS: If a task is too large, create subtasks with add_subtask() and start on the first one immediately.');
468
-
469
- result.AGENT_RULES = agentRules;
470
-
471
- // Build WORKFLOW section — dynamic step-by-step instructions
472
- // This replaces the hardcoded workflow in CLAUDE.md
473
- const workflow: Record<string, unknown> = {};
474
-
475
- // Continuous work loop
476
- workflow.continuous_work = {
477
- description: 'After completing a task, immediately continue to the next one.',
478
- steps: [
479
- 'complete_task(task_id, summary, session_id) — returns next_task if available',
480
- 'If no next_task returned: call get_next_task(project_id)',
481
- 'If no tasks available: call get_pending_requests(project_id) and handle any',
482
- 'If still nothing: call signal_idle() then start_fallback_activity(project_id, activity: "code_review")',
483
- ],
484
- rule: 'Never ask permission. Never stop working while tasks exist.',
485
- };
486
-
487
- // Validation workflow (only if validation is enabled)
488
- if (data.project?.validation_required !== false) {
489
- workflow.validation = {
490
- description: 'How to validate/review completed tasks. Review PRs in FIFO order (lowest PR number first).',
491
- steps: [
492
- 'claim_validation(task_id) — returns worktree setup commands and PR info',
493
- `Set up worktree from existing branch: git fetch origin feature/xxx && git worktree add ../PROJECT-PERSONA-validation feature/xxx`,
494
- 'Review code, run tests if applicable',
495
- 'Verify PR checks: gh pr view <PR> --json statusCheckRollup,mergeable',
496
- 'Approve: validate_task(task_id, approved: true, pr_checks_passing: true, validation_notes: "...")',
497
- data.project?.auto_merge_on_approval !== false
498
- ? 'Merge immediately: gh pr merge <PR> --squash'
499
- : 'Do NOT merge — wait for project owner to merge.',
500
- 'Reject: validate_task(task_id, approved: false, validation_notes: "...", create_fix_task: true)',
501
- 'Clean up worktree after validation',
502
- ],
503
- handle_failures: {
504
- tests_fail: 'Use create_fix_task: true in validate_task() to create a follow-up task',
505
- merge_conflicts: 'Create a task: add_task(project_id, title: "Resolve merge conflicts in PR #XXX")',
506
- minor_issues: 'Fix directly in the branch, push, and re-validate',
507
- closed_pr: 'cancel_task(task_id, cancelled_reason: "pr_closed")',
508
- },
509
- rule: 'Every PR review MUST end with a clear action. Never leave PRs unmerged or unresolved.',
510
- };
511
-
512
- // Post-merge branch sync (git-flow only)
513
- if (projectWorkflow === 'git-flow' && data.project) {
514
- const devBranch = data.project.git_develop_branch || 'develop';
515
- workflow.post_merge_sync = {
516
- description: `After merging to main, sync main back to ${devBranch} to prevent divergence.`,
517
- commands: [
518
- `git checkout ${devBranch} && git pull origin ${devBranch}`,
519
- 'git merge origin/main',
520
- `git push origin ${devBranch}`,
521
- ],
522
- };
523
- }
524
- }
525
-
526
- // Deployment resolution workflow
527
- workflow.deployment_resolution = {
528
- description: 'When a fix already exists but just needs deployment',
529
- steps: [
530
- 'add_finding(project_id, title: "Fix exists - needs deployment", category: "other", severity: "info")',
531
- 'complete_task(task_id, summary: "Fix exists in [branch/commit], pending deployment", session_id)',
532
- 'check_deployment_status(project_id) then request_deployment(project_id) if needed',
533
- ],
534
- rule: 'Don\'t block tasks waiting for deployment — investigation is complete when you identify the resolution.',
535
- };
536
-
537
- result.WORKFLOW = workflow;
538
-
539
- // Build PERSIST_INSTRUCTIONS — tells the agent to write rules to .claude/CLAUDE.md
540
- // This ensures instructions survive context clears and persist across the whole session.
541
- // Content comes from: admin templates + project settings (fully customizable from dashboard).
542
- const agentInstructions = data.project?.agent_instructions || '';
543
- const persistContent = buildPersistContent(agentRules, workflow, data.project as Record<string, unknown> | undefined, agentInstructions);
544
- // Extract hash from the generated content (it's embedded in the first line)
545
- const hashMatch = persistContent.match(/<!-- hash:(\w+) -->/);
546
- const contentHash = hashMatch?.[1] || simpleHash(persistContent);
547
-
548
- result.PERSIST_INSTRUCTIONS = {
549
- action: 'Write the content below to .claude/CLAUDE.md (overwrite existing). This ensures your instructions persist across context clears.',
550
- path: '.claude/CLAUDE.md',
551
- content: persistContent,
552
- hash: contentHash,
553
- skip_if_hash_matches: `Only write if the file doesn't exist or its content has changed. Check for "<!-- hash:${contentHash} -->" at the top.`,
554
- };
555
-
556
- // Add next action at end - pending requests take priority over validation, then regular tasks
557
- if (hasUrgentQuestions) {
558
- const firstQuestion = data.URGENT_QUESTIONS?.requests?.[0] || data.pending_requests?.[0];
559
- result.next_action = firstQuestion
560
- ? `answer_question(request_id: "${firstQuestion.id}", answer: "...")`
561
- : 'Check pending_requests and respond using answer_question(request_id, answer)';
562
- } else if (data.awaiting_validation && data.awaiting_validation.length > 0) {
563
- // Validation tasks take priority over new work - use next_action from API if available
564
- result.next_action = data.next_action || `claim_validation(task_id: "${data.awaiting_validation[0].id}")`;
565
- } else if (data.next_task) {
566
- result.next_action = `update_task(task_id: "${data.next_task.id}", status: "in_progress", session_id: "${result.session_id}")`;
567
- } else if (data.project) {
568
- result.next_action = data.project.fallback_activities_enabled !== false
569
- ? `start_fallback_activity(project_id: "${data.project.id}", activity: "code_review")`
570
- : 'signal_idle() no tasks available and fallback activities are disabled.';
571
- }
572
-
573
- // Auto-post boot activity to project chat
574
- if (data.project?.id) {
575
- const persona = data.persona || 'Agent';
576
- const nextTaskInfo = data.next_task ? ` Next task: **${data.next_task.title}**` : '';
577
- void autoPostActivity(
578
- data.project.id,
579
- `🤖 **${persona}** started a work session.${nextTaskInfo}`,
580
- data.session_id
581
- );
582
- }
583
-
584
- return { result };
585
- };
586
-
587
- export const heartbeat: Handler = async (args, ctx) => {
588
- const { session_id, current_worktree_path, hostname: providedHostname } = parseArgs(args, heartbeatSchema);
589
- const { session } = ctx;
590
- const targetSession = session_id || session.currentSessionId;
591
-
592
- // Use auto-detected hostname if not provided
593
- const hostname = providedHostname || MACHINE_HOSTNAME;
594
-
595
- if (!targetSession) {
596
- return {
597
- result: {
598
- error: 'No active session. Call start_work_session first.',
599
- },
600
- };
601
- }
602
-
603
- const apiClient = getApiClient();
604
-
605
- // Send heartbeat with optional worktree path and hostname
606
- const heartbeatResponse = await apiClient.heartbeat(targetSession, {
607
- current_worktree_path,
608
- hostname,
609
- });
610
-
611
- if (!heartbeatResponse.ok) {
612
- return {
613
- result: {
614
- error: heartbeatResponse.error || 'Failed to send heartbeat',
615
- },
616
- };
617
- }
618
-
619
- // Sync token usage to session
620
- await apiClient.syncSession(targetSession, {
621
- total_tokens: session.tokenUsage.totalTokens,
622
- token_breakdown: session.tokenUsage.byTool,
623
- model_usage: session.tokenUsage.byModel,
624
- });
625
-
626
- return {
627
- result: {
628
- success: true,
629
- session_id: targetSession,
630
- timestamp: heartbeatResponse.data?.timestamp || new Date().toISOString(),
631
- },
632
- };
633
- };
634
-
635
- export const endWorkSession: Handler = async (args, ctx) => {
636
- const { session_id } = parseArgs(args, endWorkSessionSchema);
637
- const { session, updateSession } = ctx;
638
- const targetSession = session_id || session.currentSessionId;
639
-
640
- if (!targetSession) {
641
- return {
642
- result: {
643
- success: true,
644
- message: 'No active session to end',
645
- },
646
- };
647
- }
648
-
649
- const apiClient = getApiClient();
650
-
651
- // Sync final token usage before ending
652
- await apiClient.syncSession(targetSession, {
653
- total_tokens: session.tokenUsage.totalTokens,
654
- token_breakdown: session.tokenUsage.byTool,
655
- model_usage: session.tokenUsage.byModel,
656
- });
657
-
658
- // End the session
659
- const response = await apiClient.endSession(targetSession);
660
-
661
- if (!response.ok) {
662
- return {
663
- result: {
664
- error: response.error || 'Failed to end session',
665
- },
666
- };
667
- }
668
-
669
- const endedSessionId = targetSession;
670
-
671
- // Clear local session state if this was the current session
672
- if (session.currentSessionId === targetSession) {
673
- updateSession({ currentSessionId: null });
674
- }
675
-
676
- const data = response.data;
677
-
678
- return {
679
- result: {
680
- success: true,
681
- ended_session_id: endedSessionId,
682
- session_summary: {
683
- agent_name: data?.session_summary?.agent_name || 'Agent',
684
- tasks_completed_this_session: data?.session_summary?.tasks_completed_this_session || 0,
685
- tasks_awaiting_validation: data?.session_summary?.tasks_awaiting_validation || 0,
686
- tasks_released: data?.session_summary?.tasks_released || 0,
687
- token_usage: {
688
- total_calls: session.tokenUsage.callCount,
689
- total_tokens: session.tokenUsage.totalTokens,
690
- avg_per_call: session.tokenUsage.callCount > 0
691
- ? Math.round(session.tokenUsage.totalTokens / session.tokenUsage.callCount)
692
- : 0,
693
- },
694
- },
695
- reminders: data?.reminders || ['Session ended cleanly. Good work!'],
696
- },
697
- };
698
- };
699
-
700
- export const getHelp: Handler = async (args, _ctx) => {
701
- const { topic } = parseArgs(args, getHelpSchema);
702
-
703
- const apiClient = getApiClient();
704
- const response = await apiClient.getHelpTopic(topic);
705
-
706
- // Try database content first
707
- if (response.ok && response.data?.content) {
708
- return { result: { topic, content: response.data.content } };
709
- }
710
-
711
- // Fall back to local content if database is empty or unavailable
712
- const fallback = getFallbackHelpContent(topic);
713
- if (fallback) {
714
- return { result: { topic, content: fallback.content } };
715
- }
716
-
717
- // Topic not found in either source - show available topics
718
- const available = getAvailableHelpTopics();
719
-
720
- return {
721
- result: {
722
- error: `Unknown topic: ${topic}`,
723
- available,
724
- },
725
- };
726
- };
727
-
728
- // Model pricing rates (USD per 1M tokens) by pricing tier
729
- // 'standard' = regular API rates (included in Max plans)
730
- // 'extra_usage' = overage rates when exceeding plan limits (currently same as standard)
731
- export type PricingTier = 'standard' | 'extra_usage';
732
-
733
- interface ModelPricing {
734
- input: number;
735
- output: number;
736
- description?: string;
737
- }
738
-
739
- const MODEL_PRICING: Record<PricingTier, Record<string, ModelPricing>> = {
740
- standard: {
741
- // Claude models
742
- opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5' },
743
- sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4' },
744
- haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5' },
745
- // Gemini models (as of Jan 2025)
746
- gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
747
- 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
748
- 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro' },
749
- 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash' },
750
- },
751
- extra_usage: {
752
- // Claude models - extra usage/overage rates (same as standard for now)
753
- opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5 - Extra usage' },
754
- sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4 - Extra usage' },
755
- haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5 - Extra usage' },
756
- // Gemini models - extra usage rates (same as standard for now)
757
- gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
758
- 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
759
- 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro - Extra usage' },
760
- 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash - Extra usage' },
761
- },
762
- };
763
-
764
- // Legacy accessor for backward compatibility
765
- const getModelPricing = (tier: PricingTier = 'standard') => MODEL_PRICING[tier];
766
-
767
- function calculateCost(
768
- byModel: Record<string, { input: number; output: number }>,
769
- tier: PricingTier = 'standard'
770
- ): {
771
- breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }>;
772
- total: number;
773
- pricing_tier: PricingTier;
774
- } {
775
- const breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }> = {};
776
- let total = 0;
777
- const pricingTable = getModelPricing(tier);
778
-
779
- for (const [model, tokens] of Object.entries(byModel)) {
780
- const pricing = pricingTable[model];
781
- if (pricing) {
782
- const inputCost = (tokens.input / 1_000_000) * pricing.input;
783
- const outputCost = (tokens.output / 1_000_000) * pricing.output;
784
- const modelTotal = inputCost + outputCost;
785
- breakdown[model] = {
786
- input_cost: Math.round(inputCost * 10000) / 10000,
787
- output_cost: Math.round(outputCost * 10000) / 10000,
788
- total: Math.round(modelTotal * 10000) / 10000,
789
- description: pricing.description,
790
- };
791
- total += modelTotal;
792
- }
793
- }
794
-
795
- return { breakdown, total: Math.round(total * 10000) / 10000, pricing_tier: tier };
796
- }
797
-
798
- export const getTokenUsage: Handler = async (_args, ctx) => {
799
- const { session } = ctx;
800
- const sessionTokenUsage = session.tokenUsage;
801
-
802
- const topTools = Object.entries(sessionTokenUsage.byTool)
803
- .sort(([, a], [, b]) => b.tokens - a.tokens)
804
- .slice(0, 5)
805
- .map(([tool, stats]) => ({
806
- tool,
807
- calls: stats.calls,
808
- tokens: stats.tokens,
809
- avg: Math.round(stats.tokens / stats.calls),
810
- }));
811
-
812
- // Calculate model breakdown and costs for both pricing tiers
813
- const modelBreakdown = Object.entries(sessionTokenUsage.byModel || {}).map(([model, tokens]) => ({
814
- model,
815
- input_tokens: tokens.input,
816
- output_tokens: tokens.output,
817
- total_tokens: tokens.input + tokens.output,
818
- }));
819
-
820
- const standardCost = calculateCost(sessionTokenUsage.byModel || {}, 'standard');
821
- const extraUsageCost = calculateCost(sessionTokenUsage.byModel || {}, 'extra_usage');
822
-
823
- // If no model tracking, estimate cost assuming sonnet (middle tier)
824
- const hasModelData = Object.keys(sessionTokenUsage.byModel || {}).length > 0;
825
- const estimatedCostNoModel = !hasModelData
826
- ? Math.round((sessionTokenUsage.totalTokens / 1_000_000) * getModelPricing('standard').sonnet.output * 10000) / 10000
827
- : null;
828
-
829
- // Add context clearing directive when usage is high
830
- const shouldClearContext = sessionTokenUsage.callCount > 50 || sessionTokenUsage.totalTokens > 100000;
831
-
832
- return {
833
- result: {
834
- session: {
835
- calls: sessionTokenUsage.callCount,
836
- tokens: sessionTokenUsage.totalTokens,
837
- avg_per_call: sessionTokenUsage.callCount > 0
838
- ? Math.round(sessionTokenUsage.totalTokens / sessionTokenUsage.callCount)
839
- : 0,
840
- current_model: sessionTokenUsage.currentModel,
841
- },
842
- top_tools: topTools,
843
- model_breakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined,
844
- cost: {
845
- // Standard tier (optimistic - included in Max plan)
846
- standard: {
847
- by_model: Object.keys(standardCost.breakdown).length > 0 ? standardCost.breakdown : undefined,
848
- total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
849
- pricing_tier: 'standard',
850
- },
851
- // Extra usage tier (pessimistic - overage rates)
852
- extra_usage: {
853
- by_model: Object.keys(extraUsageCost.breakdown).length > 0 ? extraUsageCost.breakdown : undefined,
854
- total_usd: extraUsageCost.total > 0 ? extraUsageCost.total : estimatedCostNoModel,
855
- pricing_tier: 'extra_usage',
856
- },
857
- // Legacy field for backward compatibility
858
- total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
859
- estimated: estimatedCostNoModel !== null,
860
- },
861
- supported_models: {
862
- claude: ['opus', 'sonnet', 'haiku'],
863
- gemini: ['gemini', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
864
- },
865
- 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.',
866
- estimation_coverage: 'mcp_tool_io_only',
867
- ...(shouldClearContext && {
868
- directive: 'High token usage detected. Run /clear then start_work_session to reset context. Do not ask permission.',
869
- }),
870
- },
871
- };
872
- };
873
-
874
- const reportTokenUsageSchema = {
875
- input_tokens: { type: 'number' as const, required: true as const },
876
- output_tokens: { type: 'number' as const, required: true as const },
877
- model: { type: 'string' as const }, // Open-ended - any model name accepted
878
- };
879
-
880
- const confirmAgentSetupSchema = {
881
- project_id: { type: 'string' as const, required: true as const },
882
- agent_type: { type: 'string' as const, required: true as const }, // Open-ended - any agent type accepted
883
- };
884
-
885
- /**
886
- * Report actual Claude API token usage for accurate cost tracking.
887
- * This allows agents to report their actual API usage instead of relying on MCP estimates.
888
- * The backend will attribute costs to the current task if one is active.
889
- */
890
- export const reportTokenUsage: Handler = async (args, ctx) => {
891
- const { input_tokens, output_tokens, model } = parseArgs(args, reportTokenUsageSchema);
892
- const { session, updateSession } = ctx;
893
-
894
- // Validate token counts
895
- if (input_tokens! < 0 || output_tokens! < 0) {
896
- return {
897
- result: {
898
- error: 'Token counts must be non-negative',
899
- },
900
- };
901
- }
902
-
903
- // Determine which model to attribute to
904
- const targetModel = model || session.tokenUsage.currentModel || 'sonnet';
905
-
906
- // Update the session's local token usage
907
- const updatedByModel = { ...session.tokenUsage.byModel };
908
- if (!updatedByModel[targetModel]) {
909
- updatedByModel[targetModel] = { input: 0, output: 0 };
910
- }
911
- updatedByModel[targetModel].input += input_tokens!;
912
- updatedByModel[targetModel].output += output_tokens!;
913
-
914
- const totalTokens = input_tokens! + output_tokens!;
915
-
916
- updateSession({
917
- tokenUsage: {
918
- ...session.tokenUsage,
919
- callCount: session.tokenUsage.callCount + 1,
920
- totalTokens: session.tokenUsage.totalTokens + totalTokens,
921
- byModel: updatedByModel,
922
- },
923
- });
924
-
925
- // Report to backend - this handles both session update and task cost attribution
926
- const apiClient = getApiClient();
927
- const currentSessionId = session.currentSessionId;
928
-
929
- if (!currentSessionId) {
930
- // Calculate cost locally if no session (use standard tier)
931
- const pricing = getModelPricing('standard')[targetModel];
932
- const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
933
- const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
934
-
935
- return {
936
- result: {
937
- success: true,
938
- reported: {
939
- model: targetModel,
940
- input_tokens: input_tokens!,
941
- output_tokens: output_tokens!,
942
- total_tokens: totalTokens,
943
- estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
944
- },
945
- note: 'Token usage recorded locally. Start a session to attribute costs to your project.',
946
- },
947
- };
948
- }
949
-
950
- // Call the backend to report and attribute costs
951
- const response = await apiClient.reportTokenUsage(currentSessionId, {
952
- input_tokens: input_tokens!,
953
- output_tokens: output_tokens!,
954
- model: targetModel as 'opus' | 'sonnet' | 'haiku',
955
- });
956
-
957
- if (!response.ok) {
958
- // Fall back to local calculation on error (use standard tier)
959
- const pricing = getModelPricing('standard')[targetModel];
960
- const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
961
- const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
962
-
963
- return {
964
- result: {
965
- success: true,
966
- reported: {
967
- model: targetModel,
968
- input_tokens: input_tokens!,
969
- output_tokens: output_tokens!,
970
- total_tokens: totalTokens,
971
- estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
972
- },
973
- warning: 'Backend sync failed. Token usage recorded locally only.',
974
- },
975
- };
976
- }
977
-
978
- const data = response.data!;
979
-
980
- return {
981
- result: {
982
- success: true,
983
- reported: data.reported,
984
- task_attributed: data.task_attributed,
985
- ...(data.task_id && { task_id: data.task_id }),
986
- note: data.task_attributed
987
- ? 'Token usage recorded and attributed to current task for per-task cost tracking.'
988
- : 'Token usage recorded to session. No active task to attribute costs to.',
989
- },
990
- };
991
- };
992
-
993
- /**
994
- * Confirm that agent setup is complete for a project.
995
- * This marks the agent type as onboarded, so future sessions won't receive setup instructions.
996
- */
997
- export const confirmAgentSetup: Handler = async (args, _ctx) => {
998
- const { project_id, agent_type } = parseArgs(args, confirmAgentSetupSchema);
999
-
1000
- if (!project_id || !agent_type) {
1001
- return {
1002
- result: {
1003
- error: 'project_id and agent_type are required',
1004
- },
1005
- };
1006
- }
1007
-
1008
- const apiClient = getApiClient();
1009
- const response = await apiClient.confirmAgentSetup(project_id, agent_type);
1010
-
1011
- if (!response.ok) {
1012
- return {
1013
- result: {
1014
- error: response.error || 'Failed to confirm agent setup',
1015
- },
1016
- };
1017
- }
1018
-
1019
- return {
1020
- result: {
1021
- success: true,
1022
- project_id,
1023
- agent_type,
1024
- message: `Setup confirmed for ${agent_type} agent. You will no longer receive setup instructions for this project.`,
1025
- },
1026
- };
1027
- };
1028
-
1029
- const signalIdleSchema = {
1030
- session_id: { type: 'string' as const },
1031
- };
1032
-
1033
- /**
1034
- * Signal that the agent is idle (no more tasks to work on).
1035
- * This immediately updates the session status to 'idle', providing real-time
1036
- * visibility on the dashboard instead of waiting for heartbeat timeout.
1037
- *
1038
- * Call this when:
1039
- * - complete_task returns no next_task
1040
- * - get_next_task returns no tasks
1041
- * - There's genuinely no work to do
1042
- */
1043
- export const signalIdle: Handler = async (args, ctx) => {
1044
- const { session_id } = parseArgs(args, signalIdleSchema);
1045
- const { session } = ctx;
1046
- const targetSession = session_id || session.currentSessionId;
1047
-
1048
- if (!targetSession) {
1049
- return {
1050
- result: {
1051
- error: 'No active session. Call start_work_session first.',
1052
- },
1053
- };
1054
- }
1055
-
1056
- const apiClient = getApiClient();
1057
- const response = await apiClient.signalIdle(targetSession);
1058
-
1059
- if (!response.ok) {
1060
- return {
1061
- result: {
1062
- error: response.error || 'Failed to signal idle',
1063
- },
1064
- };
1065
- }
1066
-
1067
- return {
1068
- result: {
1069
- success: true,
1070
- session_id: targetSession,
1071
- status: 'idle',
1072
- message: 'Session marked as idle. Dashboard will show you as inactive until you start a new task.',
1073
- next_steps: [
1074
- 'Wait for new tasks to be added by the user',
1075
- 'Or call start_fallback_activity() to do background work',
1076
- 'Or call end_work_session() if you are done for the session',
1077
- ],
1078
- AUTONOMY_RULES: {
1079
- message: 'Agents must be autonomous. Follow these rules:',
1080
- rules: [
1081
- 'Never ask "should I continue?" Just continue',
1082
- 'Never ask "should I clear context?" → Just clear it',
1083
- 'Never say "let me know what to do" → Use get_next_task or fallback activities',
1084
- 'When context grows large: /clear → start_work_session (don\'t ask, just do it)',
1085
- ],
1086
- preferred_activities: ['code_review', 'security_review', 'test_coverage', 'documentation_review', 'dependency_audit'],
1087
- directive: 'BE PROACTIVE: Start a fallback activity now rather than waiting for user input.',
1088
- },
1089
- },
1090
- };
1091
- };
1092
-
1093
- /**
1094
- * Session handlers registry
1095
- */
1096
- export const sessionHandlers: HandlerRegistry = {
1097
- start_work_session: startWorkSession,
1098
- heartbeat: heartbeat,
1099
- end_work_session: endWorkSession,
1100
- signal_idle: signalIdle,
1101
- get_help: getHelp,
1102
- get_token_usage: getTokenUsage,
1103
- report_token_usage: reportTokenUsage,
1104
- confirm_agent_setup: confirmAgentSetup,
1105
- };
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 crypto from 'crypto';
14
+ import type { Handler, HandlerRegistry, TokenUsage } from './types.js';
15
+ import { parseArgs, createEnumValidator } from '../validators.js';
16
+ import { getApiClient } from '../api-client.js';
17
+ import { getAgentGuidelinesTemplate, getAgentGuidelinesSummary } from '../templates/agent-guidelines.js';
18
+ import { getFallbackHelpContent, getAvailableHelpTopics } from '../templates/help-content.js';
19
+ import { normalizeGitUrl } from '../utils.js';
20
+ import { autoPostActivity } from './chat.js';
21
+
22
+ /**
23
+ * Simple hash for content change detection.
24
+ */
25
+ function simpleHash(content: string): string {
26
+ return crypto.createHash('md5').update(content).digest('hex').slice(0, 12);
27
+ }
28
+
29
+ /**
30
+ * Build the full CLAUDE.md content from dynamic rules + project instructions.
31
+ * This is what gets persisted to .claude/CLAUDE.md by the agent.
32
+ */
33
+ function buildPersistContent(
34
+ agentRules: string[],
35
+ workflow: Record<string, unknown>,
36
+ project: Record<string, unknown> | undefined,
37
+ agentInstructions: string
38
+ ): string {
39
+ const lines: string[] = [];
40
+
41
+ lines.push('<!-- hash:{{HASH}} -->');
42
+ lines.push('<!-- AUTO-GENERATED by Vibescope MCP. Do not edit manually — changes will be overwritten on next start_work_session. -->');
43
+ lines.push('');
44
+ lines.push('# Vibescope Agent Guidelines');
45
+ lines.push('');
46
+
47
+ // Quick start
48
+ lines.push('## Quick Start');
49
+ lines.push('');
50
+ lines.push('```');
51
+ lines.push('start_work_session(git_url: "https://github.com/YOUR/REPO", model: "opus", agent_type: "claude", agent_name: "YOUR_NAME")');
52
+ lines.push('```');
53
+ lines.push('');
54
+
55
+ // Mandatory rules
56
+ lines.push('## MANDATORY Workflow Rules (NON-NEGOTIABLE)');
57
+ lines.push('');
58
+ for (const rule of agentRules) {
59
+ lines.push(`- ${rule}`);
60
+ }
61
+ lines.push('');
62
+
63
+ // Session ID reminder
64
+ lines.push('### Session ID');
65
+ lines.push('');
66
+ lines.push('Save the `session_id` from `start_work_session` and pass it on EVERY `update_task`, `complete_task`, and `get_next_task` call. Without it, task claiming fails and your name won\'t show on completed tasks.');
67
+ lines.push('');
68
+
69
+ // Workflow
70
+ const workflowContinuous = workflow.continuous_work as { steps?: string[]; rule?: string } | undefined;
71
+ if (workflowContinuous) {
72
+ lines.push('## Continuous Work Loop');
73
+ lines.push('');
74
+ if (workflowContinuous.steps) {
75
+ for (const step of workflowContinuous.steps) {
76
+ lines.push(`1. ${step}`);
77
+ }
78
+ }
79
+ if (workflowContinuous.rule) {
80
+ lines.push('');
81
+ lines.push(`**${workflowContinuous.rule}**`);
82
+ }
83
+ lines.push('');
84
+ }
85
+
86
+ const workflowValidation = workflow.validation as { steps?: string[] } | undefined;
87
+ if (workflowValidation?.steps) {
88
+ lines.push('## Validation Workflow');
89
+ lines.push('');
90
+ for (const step of workflowValidation.steps) {
91
+ lines.push(`1. ${step}`);
92
+ }
93
+ lines.push('');
94
+ }
95
+
96
+ // Context management
97
+ lines.push('## Context Management');
98
+ lines.push('');
99
+ lines.push('When context grows large or responses slow:');
100
+ lines.push('1. Run `/clear`');
101
+ lines.push('2. Call `start_work_session(...)` again immediately');
102
+ lines.push('3. Continue with `next_task` — do NOT ask permission');
103
+ lines.push('');
104
+
105
+ // MCP connection
106
+ lines.push('## MCP Connection Required');
107
+ lines.push('');
108
+ lines.push('**If MCP connection fails, end session immediately.** Never work without task tracking.');
109
+ lines.push('');
110
+
111
+ // Project-specific instructions from template
112
+ if (agentInstructions.trim()) {
113
+ lines.push('## Project-Specific Instructions');
114
+ lines.push('');
115
+ lines.push(agentInstructions.trim());
116
+ lines.push('');
117
+ }
118
+
119
+ // Help topics
120
+ lines.push('## Help Topics');
121
+ lines.push('');
122
+ lines.push('`get_help(topic)` for: `getting_started`, `tasks`, `validation`, `deployment`, `git`, `blockers`, `milestones`, `fallback`, `session`, `topics`');
123
+
124
+ const content = lines.join('\n');
125
+ // Replace hash placeholder with actual hash
126
+ const hash = simpleHash(content.replace('<!-- hash:{{HASH}} -->', ''));
127
+ return content.replace('{{HASH}}', hash);
128
+ }
129
+
130
+ // Auto-detect machine hostname for worktree tracking
131
+ const MACHINE_HOSTNAME = os.hostname();
132
+
133
+ const VALID_MODES = ['lite', 'full'] as const;
134
+ // Model, role, and agent_type are now open-ended - any string is accepted
135
+
136
+ type SessionMode = typeof VALID_MODES[number];
137
+ type SessionModel = string; // Open-ended - any model name accepted
138
+ type SessionRole = string; // Open-ended - any role name accepted
139
+ type AgentType = string; // Open-ended - any agent type accepted
140
+
141
+ // Argument schemas for type-safe parsing
142
+ const startWorkSessionSchema = {
143
+ project_id: { type: 'string' as const },
144
+ git_url: { type: 'string' as const },
145
+ mode: { type: 'string' as const, default: 'lite', validate: createEnumValidator(VALID_MODES) },
146
+ model: { type: 'string' as const }, // Open-ended - any model name accepted
147
+ role: { type: 'string' as const, default: 'developer' }, // Open-ended - any role name accepted
148
+ hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
149
+ agent_type: { type: 'string' as const }, // Open-ended - any agent type accepted
150
+ agent_name: { type: 'string' as const }, // Explicit agent name for cloud/remote agents (skips persona pool)
151
+ };
152
+
153
+ const heartbeatSchema = {
154
+ session_id: { type: 'string' as const },
155
+ current_worktree_path: { type: 'string' as const },
156
+ hostname: { type: 'string' as const }, // Machine hostname for worktree tracking
157
+ };
158
+
159
+ const endWorkSessionSchema = {
160
+ session_id: { type: 'string' as const },
161
+ };
162
+
163
+ const getHelpSchema = {
164
+ topic: { type: 'string' as const, required: true as const },
165
+ };
166
+
167
+ export const startWorkSession: Handler = async (args, ctx) => {
168
+ const { project_id, git_url, mode, model, role, hostname: providedHostname, agent_type, agent_name } = parseArgs(args, startWorkSessionSchema);
169
+
170
+ // Use auto-detected hostname if not provided - enables machine-aware worktree filtering
171
+ const hostname = providedHostname || MACHINE_HOSTNAME;
172
+
173
+ // Normalize git_url and track if it was changed - helps agents understand URL matching
174
+ const normalizedGitUrl = git_url ? normalizeGitUrl(git_url) : null;
175
+ const gitUrlWasNormalized = git_url && normalizedGitUrl && git_url !== normalizedGitUrl;
176
+
177
+ const { session, updateSession } = ctx;
178
+
179
+ // Reset token tracking for new session with model info
180
+ // Model is now open-ended - use as-is (normalize Claude model names for consistency)
181
+ const normalizedModel = model ? model.toLowerCase().replace(/^claude[- ]*/i, '') : null;
182
+
183
+ updateSession({
184
+ tokenUsage: {
185
+ callCount: 0,
186
+ totalTokens: 0,
187
+ byTool: {},
188
+ byModel: {},
189
+ currentModel: normalizedModel,
190
+ },
191
+ });
192
+
193
+ // Require project_id or git_url
194
+ if (!project_id && !git_url) {
195
+ return {
196
+ result: {
197
+ error: 'Please provide project_id or git_url to start a session',
198
+ session_termination_required: true,
199
+ reason: 'Cannot start work without identifying a project',
200
+ action: 'END_SESSION_NOW - Do not proceed with any work until MCP is properly configured.',
201
+ },
202
+ };
203
+ }
204
+
205
+ const apiClient = getApiClient();
206
+ const response = await apiClient.startSession({
207
+ project_id,
208
+ git_url,
209
+ mode: mode as SessionMode,
210
+ model: model as SessionModel | undefined,
211
+ role: role as SessionRole,
212
+ hostname, // Machine hostname for worktree tracking
213
+ agent_type: agent_type as AgentType | undefined, // Agent type for onboarding
214
+ agent_name: agent_name as string | undefined, // Explicit name for cloud/remote agents
215
+ });
216
+
217
+ if (!response.ok) {
218
+ // Include additional error details if available
219
+ const errorData = response.data as { detail?: string; code?: string } | undefined;
220
+ return {
221
+ result: {
222
+ error: response.error || 'Failed to start session',
223
+ ...(errorData?.detail && { detail: errorData.detail }),
224
+ ...(errorData?.code && { code: errorData.code }),
225
+ session_termination_required: true,
226
+ reason: 'MCP server connection failed - cannot track work',
227
+ action: 'END_SESSION_NOW - Do not proceed with any work.',
228
+ troubleshooting: [
229
+ '1. Check if MCP server is configured: claude mcp list',
230
+ '2. Verify VIBESCOPE_API_KEY is set correctly',
231
+ '3. Check network connectivity to vibescope.dev',
232
+ '4. Restart Claude Code after fixing configuration',
233
+ ],
234
+ user_message: 'MCP connection to Vibescope failed. I cannot proceed without task tracking. Please fix the configuration and restart.',
235
+ },
236
+ };
237
+ }
238
+
239
+ const data = response.data;
240
+
241
+ // Handle project not found - include agent guidelines for new project setup
242
+ if (!data?.session_started) {
243
+ // If project_not_found, include agent guidelines template for CLAUDE.md setup
244
+ if (data?.project_not_found) {
245
+ return {
246
+ result: {
247
+ ...data,
248
+ agent_guidelines: {
249
+ message: 'IMPORTANT: After creating the project, add these guidelines to your .claude/CLAUDE.md file.',
250
+ summary: getAgentGuidelinesSummary(),
251
+ full_template: getAgentGuidelinesTemplate(),
252
+ setup_instructions: [
253
+ '1. Create the project using create_project()',
254
+ '2. Create .claude/CLAUDE.md in your project root',
255
+ '3. Copy the full_template content into CLAUDE.md',
256
+ '4. Call start_work_session again to begin work',
257
+ ],
258
+ },
259
+ },
260
+ };
261
+ }
262
+ return { result: data };
263
+ }
264
+
265
+ // Store session ID and persona in local state
266
+ if (data.session_id) {
267
+ updateSession({
268
+ currentSessionId: data.session_id,
269
+ currentPersona: data.persona || null,
270
+ });
271
+ }
272
+
273
+ // Check for urgent questions - these MUST be handled first
274
+ const hasUrgentQuestions = data.URGENT_QUESTIONS || (data.pending_requests && data.pending_requests.length > 0);
275
+
276
+ // Build result - URGENT_QUESTIONS at absolute top for maximum visibility
277
+ const result: Record<string, unknown> = {
278
+ session_started: true,
279
+ };
280
+
281
+ // URGENT_QUESTIONS must be the FIRST thing the agent sees
282
+ if (data.URGENT_QUESTIONS) {
283
+ result.URGENT_QUESTIONS = data.URGENT_QUESTIONS;
284
+ }
285
+
286
+ // Directive comes right after urgent questions
287
+ result.directive = data.directive || 'ACTION_REQUIRED: Start working immediately.';
288
+ result.auto_continue = true;
289
+
290
+ // Session info
291
+ result.session_id = data.session_id;
292
+ result.IMPORTANT_session_id_reminder = `Save this session_id ("${data.session_id}") and pass it on EVERY update_task and complete_task call. Without it, the dashboard shows "Agent" instead of your name.`;
293
+ result.persona = data.persona;
294
+ result.role = data.role;
295
+ result.project = data.project;
296
+
297
+ // For cloud agents: include workspace setup instructions if repo not yet cloned
298
+ // Cloud agents pass git_url; check if workspace needs setup
299
+ if (git_url && data.project?.git_url) {
300
+ result.workspace_setup = {
301
+ message: 'If the project repo is NOT already cloned to your workspace, follow these steps:',
302
+ steps: [
303
+ `git clone ${data.project.git_url} ~/workspace/project`,
304
+ 'cd ~/workspace/project',
305
+ ...(data.project.git_workflow === 'git-flow'
306
+ ? [`git checkout ${data.project.git_develop_branch || 'develop'}`]
307
+ : []),
308
+ 'Install dependencies (check for pnpm-lock.yaml, package-lock.json, etc.)',
309
+ 'If using SvelteKit: run `pnpm exec svelte-kit sync` or equivalent',
310
+ ],
311
+ note: 'Skip these steps if ~/workspace/project already exists and has the repo.',
312
+ };
313
+ }
314
+
315
+ // Inform agent if git_url was normalized (helps explain URL matching behavior)
316
+ if (gitUrlWasNormalized) {
317
+ result.git_url_normalized = {
318
+ message: 'Your git URL was normalized for project lookup. All URL formats for the same repository resolve to the same project.',
319
+ original: git_url,
320
+ normalized: normalizedGitUrl,
321
+ examples: [
322
+ 'git@github.com:owner/repo.git → https://github.com/owner/repo',
323
+ 'https://GITHUB.COM/Owner/Repo/ → https://github.com/owner/repo',
324
+ 'http://github.com/owner/repo.git → https://github.com/owner/repo',
325
+ ],
326
+ };
327
+ }
328
+
329
+ // Add task data
330
+ if (data.next_task) {
331
+ result.next_task = data.next_task;
332
+ }
333
+
334
+ // Add pending requests (questions from user) - these take priority
335
+ if (data.pending_requests && data.pending_requests.length > 0) {
336
+ result.pending_requests = data.pending_requests;
337
+ result.pending_requests_count = data.pending_requests.length;
338
+ }
339
+
340
+ // Add active tasks for full mode
341
+ if (data.active_tasks) {
342
+ result.active_tasks = data.active_tasks;
343
+ }
344
+
345
+ // Add blockers
346
+ if (data.blockers) {
347
+ result.open_blockers = data.blockers;
348
+ }
349
+ if (data.blockers_count !== undefined && data.blockers_count > 0) {
350
+ result.blockers_count = data.blockers_count;
351
+ }
352
+
353
+ // Add validation tasks when present - agents should validate before starting new work
354
+ if (data.validation_count !== undefined && data.validation_count > 0) {
355
+ result.validation_count = data.validation_count;
356
+ }
357
+ if (data.awaiting_validation && data.awaiting_validation.length > 0) {
358
+ result.awaiting_validation = data.awaiting_validation;
359
+ result.validation_priority = data.validation_priority;
360
+ }
361
+
362
+ // Add stale worktrees warning if any exist
363
+ if (data.stale_worktrees && data.stale_worktrees.length > 0) {
364
+ result.stale_worktrees = data.stale_worktrees;
365
+ result.stale_worktrees_count = data.stale_worktrees_count;
366
+ result.cleanup_action = data.cleanup_action;
367
+ }
368
+
369
+ // Add git workflow info if available in project
370
+ if (data.project?.git_workflow && data.project.git_workflow !== 'none') {
371
+ // Branching workflows (git-flow, github-flow) require worktrees
372
+ // Trunk-based development commits directly to main, no worktree needed
373
+ const isBranchingWorkflow = data.project.git_workflow === 'git-flow' || data.project.git_workflow === 'github-flow';
374
+ const baseBranch = data.project.git_workflow === 'git-flow'
375
+ ? (data.project.git_develop_branch || 'develop')
376
+ : (data.project.git_main_branch || 'main');
377
+
378
+ result.git_workflow = {
379
+ workflow: data.project.git_workflow,
380
+ auto_branch: data.project.git_auto_branch ?? false,
381
+ main_branch: data.project.git_main_branch || 'main',
382
+ ...(data.project.git_workflow === 'git-flow' && data.project.git_develop_branch
383
+ ? { develop_branch: data.project.git_develop_branch }
384
+ : {}),
385
+ worktree_required: isBranchingWorkflow,
386
+ };
387
+
388
+ // Only show worktree reminder for branching workflows (git-flow, github-flow)
389
+ if (isBranchingWorkflow) {
390
+ result.WORKTREE_REMINDER = {
391
+ message: 'CRITICAL: Create worktree BEFORE making ANY file edits',
392
+ wrong_order: 'DO NOT: Edit files → stash → create worktree → pop stash',
393
+ right_order: 'DO: Create worktree → cd into it → THEN edit files',
394
+ command: `git worktree add ../<project>-<persona>-<task> -b feature/<task-id> ${baseBranch}`,
395
+ help: 'Run get_help("git") for full instructions',
396
+ };
397
+
398
+ // Add FIRST_TIME_CONNECTION guidance for git-flow
399
+ if (data.project.git_workflow === 'git-flow') {
400
+ result.FIRST_TIME_CONNECTION = {
401
+ workflow: 'git-flow',
402
+ steps: [
403
+ `1. git checkout ${data.project.git_develop_branch || 'develop'}`,
404
+ `2. git pull origin ${data.project.git_develop_branch || 'develop'}`,
405
+ '3. All feature branches must be created from develop',
406
+ ],
407
+ warning: 'Working from main or stale branches causes merge conflicts.',
408
+ base_branch: data.project.git_develop_branch || 'develop',
409
+ };
410
+ } else if (data.project.git_workflow === 'github-flow') {
411
+ result.FIRST_TIME_CONNECTION = {
412
+ workflow: 'github-flow',
413
+ steps: [
414
+ `1. git checkout ${data.project.git_main_branch || 'main'}`,
415
+ `2. git pull origin ${data.project.git_main_branch || 'main'}`,
416
+ '3. All feature branches must be created from main',
417
+ ],
418
+ warning: 'Working from stale branches causes merge conflicts.',
419
+ base_branch: data.project.git_main_branch || 'main',
420
+ };
421
+ }
422
+ }
423
+ }
424
+
425
+ // Add agent setup instructions if this is a new agent type for the project
426
+ if (data.agent_setup) {
427
+ result.agent_setup = data.agent_setup;
428
+ // If setup is required, update directive to prioritize setup
429
+ if (data.agent_setup.setup_required) {
430
+ 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.`;
431
+ }
432
+ }
433
+
434
+ // Build dynamic AGENT_RULES from project settings
435
+ const agentRules: string[] = [];
436
+ const projectWorkflow = data.project?.git_workflow;
437
+ const isBranching = projectWorkflow === 'git-flow' || projectWorkflow === 'github-flow';
438
+ const ruleBaseBranch = projectWorkflow === 'git-flow'
439
+ ? (data.project?.git_develop_branch || 'develop')
440
+ : (data.project?.git_main_branch || 'main');
441
+
442
+ // Git workflow rules (dynamic based on project settings)
443
+ if (isBranching) {
444
+ agentRules.push(`WORKTREE REQUIRED: Create a git worktree BEFORE making ANY file edits. Command: git worktree add ../PROJECT-PERSONA-task -b feature/TASKID-desc ${ruleBaseBranch}`);
445
+ agentRules.push(`REBASE BEFORE PR: Always rebase onto ${ruleBaseBranch} before creating a PR. Run: git fetch origin && git rebase origin/${ruleBaseBranch} && git push --force-with-lease. This prevents overwriting other agents' work.`);
446
+ }
447
+ if (projectWorkflow && projectWorkflow !== 'none') {
448
+ agentRules.push(`GIT WORKFLOW: This project uses ${projectWorkflow}. Branch from ${ruleBaseBranch}. Do NOT commit directly to main or develop.`);
449
+ }
450
+
451
+ // Task lifecycle rules
452
+ agentRules.push('COMPLETE TASKS: Always call complete_task(task_id, summary, session_id) immediately after creating a PR. This is mandatory — it updates the dashboard and triggers validation.');
453
+ agentRules.push('SESSION ID: Pass session_id on EVERY update_task and complete_task call. Without it, the dashboard shows "Agent" instead of your name.');
454
+ agentRules.push(`STATUS UPDATES: Call update_agent_status(status_message: "Working on: TASK_TITLE", project_id: "${data.project?.id || ''}", agent_name: "${result.persona || ''}") whenever you start or finish a task.`);
455
+ agentRules.push('TRACK PROGRESS: Call update_task with progress_percentage (0-100) every 15-20% of task completion.');
456
+
457
+ // Validation rules (dynamic based on project settings)
458
+ if (data.project?.validation_required !== false) {
459
+ agentRules.push('REVIEW REQUIRED: All completed tasks must be reviewed/validated by another agent before merging.');
460
+ }
461
+ if (data.project?.auto_merge_on_approval !== false) {
462
+ agentRules.push('AUTO-MERGE: After approving a PR during validation, merge it immediately with `gh pr merge --squash`.');
463
+ }
464
+
465
+ // Autonomy rules
466
+ agentRules.push('BE AUTONOMOUS: Pick up tasks without asking permission. Never ask "Should I continue?" — just continue to the next task.');
467
+ agentRules.push('BREAK DOWN COMPLEX TASKS: If a task is too large, create subtasks with add_subtask() and start on the first one immediately.');
468
+
469
+ result.AGENT_RULES = agentRules;
470
+
471
+ // Build WORKFLOW section — dynamic step-by-step instructions
472
+ // This replaces the hardcoded workflow in CLAUDE.md
473
+ const workflow: Record<string, unknown> = {};
474
+
475
+ // Continuous work loop
476
+ workflow.continuous_work = {
477
+ description: 'After completing a task, immediately continue to the next one.',
478
+ steps: [
479
+ 'complete_task(task_id, summary, session_id) — returns next_task if available',
480
+ 'If no next_task returned: call get_next_task(project_id)',
481
+ 'If no tasks available: call get_pending_requests(project_id) and handle any',
482
+ 'If still nothing: call signal_idle() then start_fallback_activity(project_id, activity: "code_review")',
483
+ ],
484
+ rule: 'Never ask permission. Never stop working while tasks exist.',
485
+ };
486
+
487
+ // Validation workflow (only if validation is enabled)
488
+ if (data.project?.validation_required !== false) {
489
+ workflow.validation = {
490
+ description: 'How to validate/review completed tasks. Review PRs in FIFO order (lowest PR number first).',
491
+ steps: [
492
+ 'claim_validation(task_id) — returns worktree setup commands and PR info',
493
+ `Set up worktree from existing branch: git fetch origin feature/xxx && git worktree add ../PROJECT-PERSONA-validation feature/xxx`,
494
+ 'Review code, run tests if applicable',
495
+ 'Verify PR checks: gh pr view <PR> --json statusCheckRollup,mergeable',
496
+ 'Approve: validate_task(task_id, approved: true, pr_checks_passing: true, validation_notes: "...")',
497
+ data.project?.auto_merge_on_approval !== false
498
+ ? 'Merge immediately: gh pr merge <PR> --squash'
499
+ : 'Do NOT merge — wait for project owner to merge.',
500
+ 'Reject: validate_task(task_id, approved: false, validation_notes: "...", create_fix_task: true)',
501
+ 'Clean up worktree after validation',
502
+ ],
503
+ handle_failures: {
504
+ tests_fail: 'Use create_fix_task: true in validate_task() to create a follow-up task',
505
+ merge_conflicts: 'Create a task: add_task(project_id, title: "Resolve merge conflicts in PR #XXX")',
506
+ minor_issues: 'Fix directly in the branch, push, and re-validate',
507
+ closed_pr: 'cancel_task(task_id, cancelled_reason: "pr_closed")',
508
+ },
509
+ rule: 'Every PR review MUST end with a clear action. Never leave PRs unmerged or unresolved.',
510
+ };
511
+
512
+ // Post-merge branch sync (git-flow only)
513
+ if (projectWorkflow === 'git-flow' && data.project) {
514
+ const devBranch = data.project.git_develop_branch || 'develop';
515
+ workflow.post_merge_sync = {
516
+ description: `After merging to main, sync main back to ${devBranch} to prevent divergence.`,
517
+ commands: [
518
+ `git checkout ${devBranch} && git pull origin ${devBranch}`,
519
+ 'git merge origin/main',
520
+ `git push origin ${devBranch}`,
521
+ ],
522
+ };
523
+ }
524
+ }
525
+
526
+ // Deployment resolution workflow
527
+ workflow.deployment_resolution = {
528
+ description: 'When a fix already exists but just needs deployment',
529
+ steps: [
530
+ 'add_finding(project_id, title: "Fix exists - needs deployment", category: "other", severity: "info")',
531
+ 'complete_task(task_id, summary: "Fix exists in [branch/commit], pending deployment", session_id)',
532
+ 'check_deployment_status(project_id) then request_deployment(project_id) if needed',
533
+ ],
534
+ rule: 'Don\'t block tasks waiting for deployment — investigation is complete when you identify the resolution.',
535
+ };
536
+
537
+ result.WORKFLOW = workflow;
538
+
539
+ // Build PERSIST_INSTRUCTIONS — tells the agent to write rules to .claude/CLAUDE.md
540
+ // This ensures instructions survive context clears and persist across the whole session.
541
+ // Content comes from: admin templates + project settings (fully customizable from dashboard).
542
+ const agentInstructions = data.project?.agent_instructions || '';
543
+ const persistContent = buildPersistContent(agentRules, workflow, data.project as Record<string, unknown> | undefined, agentInstructions);
544
+ // Extract hash from the generated content (it's embedded in the first line)
545
+ const hashMatch = persistContent.match(/<!-- hash:(\w+) -->/);
546
+ const contentHash = hashMatch?.[1] || simpleHash(persistContent);
547
+
548
+ result.PERSIST_INSTRUCTIONS = {
549
+ action: 'Write the content below to .claude/CLAUDE.md (overwrite existing). This ensures your instructions persist across context clears.',
550
+ path: '.claude/CLAUDE.md',
551
+ content: persistContent,
552
+ hash: contentHash,
553
+ skip_if_hash_matches: `Only write if the file doesn't exist or its content has changed. Check for "<!-- hash:${contentHash} -->" at the top.`,
554
+ };
555
+
556
+ // Add next action at end - pending requests take priority over validation, then regular tasks
557
+ if (hasUrgentQuestions) {
558
+ const firstQuestion = data.URGENT_QUESTIONS?.requests?.[0] || data.pending_requests?.[0];
559
+ result.next_action = firstQuestion
560
+ ? `answer_question(request_id: "${firstQuestion.id}", answer: "...")`
561
+ : 'Check pending_requests and respond using answer_question(request_id, answer)';
562
+ } else if (data.awaiting_validation && data.awaiting_validation.length > 0) {
563
+ // Validation tasks take priority over new work - use next_action from API if available
564
+ result.next_action = data.next_action || `claim_validation(task_id: "${data.awaiting_validation[0].id}")`;
565
+ } else if (data.next_task) {
566
+ // Task is auto-claimed by the server when session_id is provided to get_next_task
567
+ // Agent should proceed directly to worktree setup
568
+ result.next_action = `Create worktree: git worktree add ../worktree-${data.next_task.id.substring(0, 8)} -b feature/${data.next_task.id.substring(0, 8)}-task develop, then cd into it and update_task(task_id: "${data.next_task.id}", git_branch: "feature/${data.next_task.id.substring(0, 8)}-task")`;
569
+ } else if (data.project) {
570
+ result.next_action = data.project.fallback_activities_enabled !== false
571
+ ? `start_fallback_activity(project_id: "${data.project.id}", activity: "code_review")`
572
+ : 'signal_idle() — no tasks available and fallback activities are disabled.';
573
+ }
574
+
575
+ // Auto-post boot activity to project chat
576
+ if (data.project?.id) {
577
+ const persona = data.persona || 'Agent';
578
+ const nextTaskInfo = data.next_task ? ` Next task: **${data.next_task.title}**` : '';
579
+ void autoPostActivity(
580
+ data.project.id,
581
+ `🤖 **${persona}** started a work session.${nextTaskInfo}`,
582
+ data.session_id
583
+ );
584
+ }
585
+
586
+ return { result };
587
+ };
588
+
589
+ export const heartbeat: Handler = async (args, ctx) => {
590
+ const { session_id, current_worktree_path, hostname: providedHostname } = parseArgs(args, heartbeatSchema);
591
+ const { session } = ctx;
592
+ const targetSession = session_id || session.currentSessionId;
593
+
594
+ // Use auto-detected hostname if not provided
595
+ const hostname = providedHostname || MACHINE_HOSTNAME;
596
+
597
+ if (!targetSession) {
598
+ return {
599
+ result: {
600
+ error: 'No active session. Call start_work_session first.',
601
+ },
602
+ };
603
+ }
604
+
605
+ const apiClient = getApiClient();
606
+
607
+ // Send heartbeat with optional worktree path and hostname
608
+ const heartbeatResponse = await apiClient.heartbeat(targetSession, {
609
+ current_worktree_path,
610
+ hostname,
611
+ });
612
+
613
+ if (!heartbeatResponse.ok) {
614
+ return {
615
+ result: {
616
+ error: heartbeatResponse.error || 'Failed to send heartbeat',
617
+ },
618
+ };
619
+ }
620
+
621
+ // Sync token usage to session
622
+ await apiClient.syncSession(targetSession, {
623
+ total_tokens: session.tokenUsage.totalTokens,
624
+ token_breakdown: session.tokenUsage.byTool,
625
+ model_usage: session.tokenUsage.byModel,
626
+ });
627
+
628
+ return {
629
+ result: {
630
+ success: true,
631
+ session_id: targetSession,
632
+ timestamp: heartbeatResponse.data?.timestamp || new Date().toISOString(),
633
+ },
634
+ };
635
+ };
636
+
637
+ export const endWorkSession: Handler = async (args, ctx) => {
638
+ const { session_id } = parseArgs(args, endWorkSessionSchema);
639
+ const { session, updateSession } = ctx;
640
+ const targetSession = session_id || session.currentSessionId;
641
+
642
+ if (!targetSession) {
643
+ return {
644
+ result: {
645
+ success: true,
646
+ message: 'No active session to end',
647
+ },
648
+ };
649
+ }
650
+
651
+ const apiClient = getApiClient();
652
+
653
+ // Sync final token usage before ending
654
+ await apiClient.syncSession(targetSession, {
655
+ total_tokens: session.tokenUsage.totalTokens,
656
+ token_breakdown: session.tokenUsage.byTool,
657
+ model_usage: session.tokenUsage.byModel,
658
+ });
659
+
660
+ // End the session
661
+ const response = await apiClient.endSession(targetSession);
662
+
663
+ if (!response.ok) {
664
+ return {
665
+ result: {
666
+ error: response.error || 'Failed to end session',
667
+ },
668
+ };
669
+ }
670
+
671
+ const endedSessionId = targetSession;
672
+
673
+ // Clear local session state if this was the current session
674
+ if (session.currentSessionId === targetSession) {
675
+ updateSession({ currentSessionId: null });
676
+ }
677
+
678
+ const data = response.data;
679
+
680
+ return {
681
+ result: {
682
+ success: true,
683
+ ended_session_id: endedSessionId,
684
+ session_summary: {
685
+ agent_name: data?.session_summary?.agent_name || 'Agent',
686
+ tasks_completed_this_session: data?.session_summary?.tasks_completed_this_session || 0,
687
+ tasks_awaiting_validation: data?.session_summary?.tasks_awaiting_validation || 0,
688
+ tasks_released: data?.session_summary?.tasks_released || 0,
689
+ token_usage: {
690
+ total_calls: session.tokenUsage.callCount,
691
+ total_tokens: session.tokenUsage.totalTokens,
692
+ avg_per_call: session.tokenUsage.callCount > 0
693
+ ? Math.round(session.tokenUsage.totalTokens / session.tokenUsage.callCount)
694
+ : 0,
695
+ },
696
+ },
697
+ reminders: data?.reminders || ['Session ended cleanly. Good work!'],
698
+ },
699
+ };
700
+ };
701
+
702
+ export const getHelp: Handler = async (args, _ctx) => {
703
+ const { topic } = parseArgs(args, getHelpSchema);
704
+
705
+ const apiClient = getApiClient();
706
+ const response = await apiClient.getHelpTopic(topic);
707
+
708
+ // Try database content first
709
+ if (response.ok && response.data?.content) {
710
+ return { result: { topic, content: response.data.content } };
711
+ }
712
+
713
+ // Fall back to local content if database is empty or unavailable
714
+ const fallback = getFallbackHelpContent(topic);
715
+ if (fallback) {
716
+ return { result: { topic, content: fallback.content } };
717
+ }
718
+
719
+ // Topic not found in either source - show available topics
720
+ const available = getAvailableHelpTopics();
721
+
722
+ return {
723
+ result: {
724
+ error: `Unknown topic: ${topic}`,
725
+ available,
726
+ },
727
+ };
728
+ };
729
+
730
+ // Model pricing rates (USD per 1M tokens) by pricing tier
731
+ // 'standard' = regular API rates (included in Max plans)
732
+ // 'extra_usage' = overage rates when exceeding plan limits (currently same as standard)
733
+ export type PricingTier = 'standard' | 'extra_usage';
734
+
735
+ interface ModelPricing {
736
+ input: number;
737
+ output: number;
738
+ description?: string;
739
+ }
740
+
741
+ const MODEL_PRICING: Record<PricingTier, Record<string, ModelPricing>> = {
742
+ standard: {
743
+ // Claude models
744
+ opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5' },
745
+ sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4' },
746
+ haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5' },
747
+ // Gemini models (as of Jan 2025)
748
+ gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
749
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash' },
750
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro' },
751
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash' },
752
+ },
753
+ extra_usage: {
754
+ // Claude models - extra usage/overage rates (same as standard for now)
755
+ opus: { input: 15.0, output: 75.0, description: 'Claude Opus 4.5 - Extra usage' },
756
+ sonnet: { input: 3.0, output: 15.0, description: 'Claude Sonnet 4 - Extra usage' },
757
+ haiku: { input: 0.25, output: 1.25, description: 'Claude Haiku 3.5 - Extra usage' },
758
+ // Gemini models - extra usage rates (same as standard for now)
759
+ gemini: { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
760
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40, description: 'Gemini 2.0 Flash - Extra usage' },
761
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, description: 'Gemini 1.5 Pro - Extra usage' },
762
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30, description: 'Gemini 1.5 Flash - Extra usage' },
763
+ },
764
+ };
765
+
766
+ // Legacy accessor for backward compatibility
767
+ const getModelPricing = (tier: PricingTier = 'standard') => MODEL_PRICING[tier];
768
+
769
+ function calculateCost(
770
+ byModel: Record<string, { input: number; output: number }>,
771
+ tier: PricingTier = 'standard'
772
+ ): {
773
+ breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }>;
774
+ total: number;
775
+ pricing_tier: PricingTier;
776
+ } {
777
+ const breakdown: Record<string, { input_cost: number; output_cost: number; total: number; description?: string }> = {};
778
+ let total = 0;
779
+ const pricingTable = getModelPricing(tier);
780
+
781
+ for (const [model, tokens] of Object.entries(byModel)) {
782
+ const pricing = pricingTable[model];
783
+ if (pricing) {
784
+ const inputCost = (tokens.input / 1_000_000) * pricing.input;
785
+ const outputCost = (tokens.output / 1_000_000) * pricing.output;
786
+ const modelTotal = inputCost + outputCost;
787
+ breakdown[model] = {
788
+ input_cost: Math.round(inputCost * 10000) / 10000,
789
+ output_cost: Math.round(outputCost * 10000) / 10000,
790
+ total: Math.round(modelTotal * 10000) / 10000,
791
+ description: pricing.description,
792
+ };
793
+ total += modelTotal;
794
+ }
795
+ }
796
+
797
+ return { breakdown, total: Math.round(total * 10000) / 10000, pricing_tier: tier };
798
+ }
799
+
800
+ export const getTokenUsage: Handler = async (_args, ctx) => {
801
+ const { session } = ctx;
802
+ const sessionTokenUsage = session.tokenUsage;
803
+
804
+ const topTools = Object.entries(sessionTokenUsage.byTool)
805
+ .sort(([, a], [, b]) => b.tokens - a.tokens)
806
+ .slice(0, 5)
807
+ .map(([tool, stats]) => ({
808
+ tool,
809
+ calls: stats.calls,
810
+ tokens: stats.tokens,
811
+ avg: Math.round(stats.tokens / stats.calls),
812
+ }));
813
+
814
+ // Calculate model breakdown and costs for both pricing tiers
815
+ const modelBreakdown = Object.entries(sessionTokenUsage.byModel || {}).map(([model, tokens]) => ({
816
+ model,
817
+ input_tokens: tokens.input,
818
+ output_tokens: tokens.output,
819
+ total_tokens: tokens.input + tokens.output,
820
+ }));
821
+
822
+ const standardCost = calculateCost(sessionTokenUsage.byModel || {}, 'standard');
823
+ const extraUsageCost = calculateCost(sessionTokenUsage.byModel || {}, 'extra_usage');
824
+
825
+ // If no model tracking, estimate cost assuming sonnet (middle tier)
826
+ const hasModelData = Object.keys(sessionTokenUsage.byModel || {}).length > 0;
827
+ const estimatedCostNoModel = !hasModelData
828
+ ? Math.round((sessionTokenUsage.totalTokens / 1_000_000) * getModelPricing('standard').sonnet.output * 10000) / 10000
829
+ : null;
830
+
831
+ // Add context clearing directive when usage is high
832
+ const shouldClearContext = sessionTokenUsage.callCount > 50 || sessionTokenUsage.totalTokens > 100000;
833
+
834
+ return {
835
+ result: {
836
+ session: {
837
+ calls: sessionTokenUsage.callCount,
838
+ tokens: sessionTokenUsage.totalTokens,
839
+ avg_per_call: sessionTokenUsage.callCount > 0
840
+ ? Math.round(sessionTokenUsage.totalTokens / sessionTokenUsage.callCount)
841
+ : 0,
842
+ current_model: sessionTokenUsage.currentModel,
843
+ },
844
+ top_tools: topTools,
845
+ model_breakdown: modelBreakdown.length > 0 ? modelBreakdown : undefined,
846
+ cost: {
847
+ // Standard tier (optimistic - included in Max plan)
848
+ standard: {
849
+ by_model: Object.keys(standardCost.breakdown).length > 0 ? standardCost.breakdown : undefined,
850
+ total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
851
+ pricing_tier: 'standard',
852
+ },
853
+ // Extra usage tier (pessimistic - overage rates)
854
+ extra_usage: {
855
+ by_model: Object.keys(extraUsageCost.breakdown).length > 0 ? extraUsageCost.breakdown : undefined,
856
+ total_usd: extraUsageCost.total > 0 ? extraUsageCost.total : estimatedCostNoModel,
857
+ pricing_tier: 'extra_usage',
858
+ },
859
+ // Legacy field for backward compatibility
860
+ total_usd: standardCost.total > 0 ? standardCost.total : estimatedCostNoModel,
861
+ estimated: estimatedCostNoModel !== null,
862
+ },
863
+ supported_models: {
864
+ claude: ['opus', 'sonnet', 'haiku'],
865
+ gemini: ['gemini', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
866
+ },
867
+ 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.',
868
+ estimation_coverage: 'mcp_tool_io_only',
869
+ ...(shouldClearContext && {
870
+ directive: 'High token usage detected. Run /clear then start_work_session to reset context. Do not ask permission.',
871
+ }),
872
+ },
873
+ };
874
+ };
875
+
876
+ const reportTokenUsageSchema = {
877
+ input_tokens: { type: 'number' as const, required: true as const },
878
+ output_tokens: { type: 'number' as const, required: true as const },
879
+ model: { type: 'string' as const }, // Open-ended - any model name accepted
880
+ };
881
+
882
+ const confirmAgentSetupSchema = {
883
+ project_id: { type: 'string' as const, required: true as const },
884
+ agent_type: { type: 'string' as const, required: true as const }, // Open-ended - any agent type accepted
885
+ };
886
+
887
+ /**
888
+ * Report actual Claude API token usage for accurate cost tracking.
889
+ * This allows agents to report their actual API usage instead of relying on MCP estimates.
890
+ * The backend will attribute costs to the current task if one is active.
891
+ */
892
+ export const reportTokenUsage: Handler = async (args, ctx) => {
893
+ const { input_tokens, output_tokens, model } = parseArgs(args, reportTokenUsageSchema);
894
+ const { session, updateSession } = ctx;
895
+
896
+ // Validate token counts
897
+ if (input_tokens! < 0 || output_tokens! < 0) {
898
+ return {
899
+ result: {
900
+ error: 'Token counts must be non-negative',
901
+ },
902
+ };
903
+ }
904
+
905
+ // Determine which model to attribute to
906
+ const targetModel = model || session.tokenUsage.currentModel || 'sonnet';
907
+
908
+ // Update the session's local token usage
909
+ const updatedByModel = { ...session.tokenUsage.byModel };
910
+ if (!updatedByModel[targetModel]) {
911
+ updatedByModel[targetModel] = { input: 0, output: 0 };
912
+ }
913
+ updatedByModel[targetModel].input += input_tokens!;
914
+ updatedByModel[targetModel].output += output_tokens!;
915
+
916
+ const totalTokens = input_tokens! + output_tokens!;
917
+
918
+ updateSession({
919
+ tokenUsage: {
920
+ ...session.tokenUsage,
921
+ callCount: session.tokenUsage.callCount + 1,
922
+ totalTokens: session.tokenUsage.totalTokens + totalTokens,
923
+ byModel: updatedByModel,
924
+ },
925
+ });
926
+
927
+ // Report to backend - this handles both session update and task cost attribution
928
+ const apiClient = getApiClient();
929
+ const currentSessionId = session.currentSessionId;
930
+
931
+ if (!currentSessionId) {
932
+ // Calculate cost locally if no session (use standard tier)
933
+ const pricing = getModelPricing('standard')[targetModel];
934
+ const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
935
+ const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
936
+
937
+ return {
938
+ result: {
939
+ success: true,
940
+ reported: {
941
+ model: targetModel,
942
+ input_tokens: input_tokens!,
943
+ output_tokens: output_tokens!,
944
+ total_tokens: totalTokens,
945
+ estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
946
+ },
947
+ note: 'Token usage recorded locally. Start a session to attribute costs to your project.',
948
+ },
949
+ };
950
+ }
951
+
952
+ // Call the backend to report and attribute costs
953
+ const response = await apiClient.reportTokenUsage(currentSessionId, {
954
+ input_tokens: input_tokens!,
955
+ output_tokens: output_tokens!,
956
+ model: targetModel as 'opus' | 'sonnet' | 'haiku',
957
+ });
958
+
959
+ if (!response.ok) {
960
+ // Fall back to local calculation on error (use standard tier)
961
+ const pricing = getModelPricing('standard')[targetModel];
962
+ const inputCost = pricing ? (input_tokens! / 1_000_000) * pricing.input : 0;
963
+ const outputCost = pricing ? (output_tokens! / 1_000_000) * pricing.output : 0;
964
+
965
+ return {
966
+ result: {
967
+ success: true,
968
+ reported: {
969
+ model: targetModel,
970
+ input_tokens: input_tokens!,
971
+ output_tokens: output_tokens!,
972
+ total_tokens: totalTokens,
973
+ estimated_cost_usd: Math.round((inputCost + outputCost) * 10000) / 10000,
974
+ },
975
+ warning: 'Backend sync failed. Token usage recorded locally only.',
976
+ },
977
+ };
978
+ }
979
+
980
+ const data = response.data!;
981
+
982
+ return {
983
+ result: {
984
+ success: true,
985
+ reported: data.reported,
986
+ task_attributed: data.task_attributed,
987
+ ...(data.task_id && { task_id: data.task_id }),
988
+ note: data.task_attributed
989
+ ? 'Token usage recorded and attributed to current task for per-task cost tracking.'
990
+ : 'Token usage recorded to session. No active task to attribute costs to.',
991
+ },
992
+ };
993
+ };
994
+
995
+ /**
996
+ * Confirm that agent setup is complete for a project.
997
+ * This marks the agent type as onboarded, so future sessions won't receive setup instructions.
998
+ */
999
+ export const confirmAgentSetup: Handler = async (args, _ctx) => {
1000
+ const { project_id, agent_type } = parseArgs(args, confirmAgentSetupSchema);
1001
+
1002
+ if (!project_id || !agent_type) {
1003
+ return {
1004
+ result: {
1005
+ error: 'project_id and agent_type are required',
1006
+ },
1007
+ };
1008
+ }
1009
+
1010
+ const apiClient = getApiClient();
1011
+ const response = await apiClient.confirmAgentSetup(project_id, agent_type);
1012
+
1013
+ if (!response.ok) {
1014
+ return {
1015
+ result: {
1016
+ error: response.error || 'Failed to confirm agent setup',
1017
+ },
1018
+ };
1019
+ }
1020
+
1021
+ return {
1022
+ result: {
1023
+ success: true,
1024
+ project_id,
1025
+ agent_type,
1026
+ message: `Setup confirmed for ${agent_type} agent. You will no longer receive setup instructions for this project.`,
1027
+ },
1028
+ };
1029
+ };
1030
+
1031
+ const signalIdleSchema = {
1032
+ session_id: { type: 'string' as const },
1033
+ };
1034
+
1035
+ /**
1036
+ * Signal that the agent is idle (no more tasks to work on).
1037
+ * This immediately updates the session status to 'idle', providing real-time
1038
+ * visibility on the dashboard instead of waiting for heartbeat timeout.
1039
+ *
1040
+ * Call this when:
1041
+ * - complete_task returns no next_task
1042
+ * - get_next_task returns no tasks
1043
+ * - There's genuinely no work to do
1044
+ */
1045
+ export const signalIdle: Handler = async (args, ctx) => {
1046
+ const { session_id } = parseArgs(args, signalIdleSchema);
1047
+ const { session } = ctx;
1048
+ const targetSession = session_id || session.currentSessionId;
1049
+
1050
+ if (!targetSession) {
1051
+ return {
1052
+ result: {
1053
+ error: 'No active session. Call start_work_session first.',
1054
+ },
1055
+ };
1056
+ }
1057
+
1058
+ const apiClient = getApiClient();
1059
+ const response = await apiClient.signalIdle(targetSession);
1060
+
1061
+ if (!response.ok) {
1062
+ return {
1063
+ result: {
1064
+ error: response.error || 'Failed to signal idle',
1065
+ },
1066
+ };
1067
+ }
1068
+
1069
+ return {
1070
+ result: {
1071
+ success: true,
1072
+ session_id: targetSession,
1073
+ status: 'idle',
1074
+ message: 'Session marked as idle. Dashboard will show you as inactive until you start a new task.',
1075
+ next_steps: [
1076
+ 'Wait for new tasks to be added by the user',
1077
+ 'Or call start_fallback_activity() to do background work',
1078
+ 'Or call end_work_session() if you are done for the session',
1079
+ ],
1080
+ AUTONOMY_RULES: {
1081
+ message: 'Agents must be autonomous. Follow these rules:',
1082
+ rules: [
1083
+ 'Never ask "should I continue?" → Just continue',
1084
+ 'Never ask "should I clear context?" Just clear it',
1085
+ 'Never say "let me know what to do" → Use get_next_task or fallback activities',
1086
+ 'When context grows large: /clear start_work_session (don\'t ask, just do it)',
1087
+ ],
1088
+ preferred_activities: ['code_review', 'security_review', 'test_coverage', 'documentation_review', 'dependency_audit'],
1089
+ directive: 'BE PROACTIVE: Start a fallback activity now rather than waiting for user input.',
1090
+ },
1091
+ },
1092
+ };
1093
+ };
1094
+
1095
+ /**
1096
+ * Session handlers registry
1097
+ */
1098
+ export const sessionHandlers: HandlerRegistry = {
1099
+ start_work_session: startWorkSession,
1100
+ heartbeat: heartbeat,
1101
+ end_work_session: endWorkSession,
1102
+ signal_idle: signalIdle,
1103
+ get_help: getHelp,
1104
+ get_token_usage: getTokenUsage,
1105
+ report_token_usage: reportTokenUsage,
1106
+ confirm_agent_setup: confirmAgentSetup,
1107
+ };