@fruition/fcp-mcp-server 1.19.0 → 1.21.0

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.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // UNROO CLAUDE CODE HEARTBEAT (Node version)
4
+ // ============================================================================
5
+ // Successor to unroo-heartbeat.sh. Reads Claude Code's hook stdin protocol
6
+ // properly (the shell version had no way to parse hook JSON, so `tool_name`,
7
+ // `transcript_path`, and `session_id` were never carried through).
8
+ //
9
+ // Wired into Claude Code via ~/.claude/settings.json hooks:
10
+ // PostToolUse * → unroo-heartbeat (per-turn token + skill delta)
11
+ // Stop → unroo-heartbeat session_end (final flush + session end)
12
+ //
13
+ // Per-turn delta design (the Unroo server expects this shape — see
14
+ // src/lib/unroo/ai-productivity-capture.ts). For each NEW assistant turn
15
+ // appended to the transcript since the last hook fired, we POST one
16
+ // heartbeat carrying that turn's `primary_model`, token usage (as deltas —
17
+ // the server uses Prisma `increment`), and any Skill tool invocations from
18
+ // that turn (server upserts +1 per skill).
19
+ //
20
+ // Scratch state at ~/.claude/unroo-tracking/<cc_session_id>.json keeps a
21
+ // line cursor into the transcript so we never double-count.
22
+ //
23
+ // Soft-fail everywhere — this MUST NOT block Claude Code, ever.
24
+ //
25
+ // Env:
26
+ // UNROO_API_KEY Required. Without it, exit 0 silently.
27
+ // UNROO_API_ENDPOINT Optional. Default https://app.unroo.io.
28
+ // ============================================================================
29
+
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { execSync } from 'node:child_process';
34
+ import https from 'node:https';
35
+ import http from 'node:http';
36
+
37
+ const API_KEY = process.env.UNROO_API_KEY;
38
+ if (!API_KEY) process.exit(0); // No key set — silent no-op (matches old behaviour).
39
+
40
+ const API_BASE = process.env.UNROO_API_ENDPOINT || 'https://app.unroo.io';
41
+ const API_URL = `${API_BASE.replace(/\/$/, '')}/api/unroo/claude-tracker/heartbeat`;
42
+
43
+ const SCRATCH_DIR = path.join(os.homedir(), '.claude', 'unroo-tracking');
44
+
45
+ // ── stdin (Claude Code hook payload, JSON) ───────────────────────────────────
46
+
47
+ function readStdin() {
48
+ return new Promise((resolve) => {
49
+ if (process.stdin.isTTY) return resolve(null);
50
+ let data = '';
51
+ let settled = false;
52
+ const done = () => {
53
+ if (settled) return;
54
+ settled = true;
55
+ try { resolve(data ? JSON.parse(data) : null); } catch { resolve(null); }
56
+ };
57
+ process.stdin.setEncoding('utf8');
58
+ process.stdin.on('data', (chunk) => { data += chunk; });
59
+ process.stdin.on('end', done);
60
+ process.stdin.on('error', () => done());
61
+ setTimeout(done, 800); // Safety — never block forever.
62
+ });
63
+ }
64
+
65
+ // ── small utility helpers ────────────────────────────────────────────────────
66
+
67
+ function sh(cmd) {
68
+ try {
69
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
70
+ } catch { return ''; }
71
+ }
72
+
73
+ function machineId() {
74
+ try {
75
+ if (fs.existsSync('/etc/machine-id')) {
76
+ return fs.readFileSync('/etc/machine-id', 'utf8').trim().slice(0, 8);
77
+ }
78
+ if (fs.existsSync('/var/lib/dbus/machine-id')) {
79
+ return fs.readFileSync('/var/lib/dbus/machine-id', 'utf8').trim().slice(0, 8);
80
+ }
81
+ const ioreg = sh('ioreg -rd1 -c IOPlatformExpertDevice');
82
+ const m = ioreg.match(/IOPlatformUUID"\s*=\s*"([^"]+)"/);
83
+ if (m) return m[1].slice(0, 8);
84
+ return sh('hostid').slice(0, 8);
85
+ } catch { return ''; }
86
+ }
87
+
88
+ function projectSlugFromCwd() {
89
+ try {
90
+ if (!fs.existsSync('.unroo')) return '';
91
+ const text = fs.readFileSync('.unroo', 'utf8');
92
+ const m = text.match(/^project_slug=(.+)$/m);
93
+ return m ? m[1].trim() : '';
94
+ } catch { return ''; }
95
+ }
96
+
97
+ // ── scratch state (per Claude Code session_id) ───────────────────────────────
98
+
99
+ function scratchPathFor(sessionId) {
100
+ if (!sessionId) return null;
101
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 80);
102
+ return path.join(SCRATCH_DIR, `${safe}.json`);
103
+ }
104
+
105
+ function readScratch(sessionId) {
106
+ const p = scratchPathFor(sessionId);
107
+ if (!p || !fs.existsSync(p)) return { last_line_index: 0 };
108
+ try {
109
+ const s = JSON.parse(fs.readFileSync(p, 'utf8'));
110
+ return { last_line_index: Number(s.last_line_index) || 0 };
111
+ } catch {
112
+ return { last_line_index: 0 };
113
+ }
114
+ }
115
+
116
+ function writeScratch(sessionId, state) {
117
+ const p = scratchPathFor(sessionId);
118
+ if (!p) return;
119
+ try {
120
+ fs.mkdirSync(SCRATCH_DIR, { recursive: true });
121
+ fs.writeFileSync(p, JSON.stringify(state));
122
+ } catch { /* best-effort */ }
123
+ }
124
+
125
+ function deleteScratch(sessionId) {
126
+ const p = scratchPathFor(sessionId);
127
+ if (!p) return;
128
+ try { fs.unlinkSync(p); } catch { /* missing-ok */ }
129
+ }
130
+
131
+ // ── transcript parsing — extract NEW assistant turns since `fromIndex` ───────
132
+
133
+ /**
134
+ * Parse the transcript JSONL starting at `fromIndex`. Returns one entry per
135
+ * assistant message that carries a `message.usage` block, plus the new line
136
+ * cursor. Skills are extracted per-turn from `tool_use` blocks with name
137
+ * "Skill". Tokens come straight from the per-turn `usage`.
138
+ */
139
+ function parseNewTurns(transcriptPath, fromIndex) {
140
+ const turns = [];
141
+ let newIndex = fromIndex;
142
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
143
+ return { turns, newIndex };
144
+ }
145
+
146
+ let buf;
147
+ try { buf = fs.readFileSync(transcriptPath, 'utf8'); } catch { return { turns, newIndex }; }
148
+ const lines = buf.split('\n');
149
+
150
+ for (let i = fromIndex; i < lines.length; i++) {
151
+ newIndex = i + 1;
152
+ const line = lines[i];
153
+ if (!line) continue;
154
+
155
+ let row;
156
+ try { row = JSON.parse(line); } catch { continue; }
157
+ const msg = row.message;
158
+ if (!msg || typeof msg !== 'object') continue;
159
+ if (!msg.usage || !msg.model) continue;
160
+
161
+ const skills = [];
162
+ if (Array.isArray(msg.content)) {
163
+ for (const block of msg.content) {
164
+ if (block && block.type === 'tool_use' && block.name === 'Skill') {
165
+ const input = block.input || {};
166
+ const slug = input.skill || input.name || null;
167
+ if (slug) skills.push({ slug: String(slug), name: String(slug), source: 'local' });
168
+ }
169
+ }
170
+ }
171
+
172
+ turns.push({
173
+ model: String(msg.model),
174
+ tokens_input: Number(msg.usage.input_tokens || 0) || 0,
175
+ tokens_output: Number(msg.usage.output_tokens || 0) || 0,
176
+ tokens_cache_read: Number(msg.usage.cache_read_input_tokens || 0) || 0,
177
+ tokens_cache_write: Number(msg.usage.cache_creation_input_tokens || 0) || 0,
178
+ skills,
179
+ });
180
+ }
181
+
182
+ return { turns, newIndex };
183
+ }
184
+
185
+ // ── HTTP POST (fire-and-forget, short timeout) ───────────────────────────────
186
+
187
+ function postJson(payload) {
188
+ return new Promise((resolve) => {
189
+ let u;
190
+ try { u = new URL(API_URL); } catch { return resolve(); }
191
+ const lib = u.protocol === 'https:' ? https : http;
192
+ const body = JSON.stringify(payload);
193
+ const req = lib.request(
194
+ {
195
+ method: 'POST',
196
+ hostname: u.hostname,
197
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
198
+ path: `${u.pathname}${u.search || ''}`,
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'Content-Length': Buffer.byteLength(body),
202
+ },
203
+ timeout: 3000,
204
+ },
205
+ (res) => {
206
+ res.resume();
207
+ res.on('end', () => resolve());
208
+ res.on('error', () => resolve());
209
+ },
210
+ );
211
+ req.on('error', () => resolve());
212
+ req.on('timeout', () => { req.destroy(); resolve(); });
213
+ req.write(body);
214
+ req.end();
215
+ });
216
+ }
217
+
218
+ // ── main ─────────────────────────────────────────────────────────────────────
219
+
220
+ (async () => {
221
+ const hook = await readStdin();
222
+ const argEvent = process.argv[2];
223
+ const hookEvent = hook && typeof hook.hook_event_name === 'string' ? hook.hook_event_name : null;
224
+
225
+ let eventType = argEvent || 'tool_end';
226
+ if (hookEvent === 'Stop' || hookEvent === 'SubagentStop') eventType = 'session_end';
227
+ else if (hookEvent === 'PostToolUse') eventType = 'tool_end';
228
+
229
+ const ccSessionId = (hook && hook.session_id) || null;
230
+ const transcriptPath = (hook && hook.transcript_path) || null;
231
+
232
+ const gitRemote = sh('git remote get-url origin');
233
+ const gitBranch = sh('git symbolic-ref --short HEAD');
234
+ const repoName = path.basename(process.cwd());
235
+
236
+ const baseFields = {
237
+ api_key: API_KEY,
238
+ source: 'claude-code',
239
+ git_remote_url: gitRemote || '',
240
+ git_branch: gitBranch || '',
241
+ repo_name: repoName,
242
+ machine_id: machineId(),
243
+ tool_name: (hook && hook.tool_name) || process.env.TOOL_NAME || '',
244
+ tool_description: process.env.TOOL_DESCRIPTION || '',
245
+ project_slug: projectSlugFromCwd(),
246
+ cc_session_id: ccSessionId,
247
+ actor_type: 'interactive_agent',
248
+ };
249
+
250
+ const scratch = readScratch(ccSessionId);
251
+ const { turns, newIndex } = parseNewTurns(transcriptPath, scratch.last_line_index);
252
+
253
+ // Persist the cursor before any POSTs so a crashed hook still moves forward
254
+ // (better to miss one turn than to double-count it on the next call).
255
+ writeScratch(ccSessionId, { last_line_index: newIndex });
256
+
257
+ // Each new assistant turn is posted as tool_end. The server's tool_end
258
+ // branch creates or updates the session row and accumulates the per-turn
259
+ // token delta + skills via mergeModelUsage / recordSkillInvocations.
260
+ //
261
+ // Sequential — NOT parallel — because the first POST of a brand-new
262
+ // session triggers auto-create of a claude_repo_mappings row; running
263
+ // them in parallel races the unique constraint on (org, git_remote_url)
264
+ // and one POST errors out. In practice each hook fire has 0-1 new turns
265
+ // so sequential is effectively the same speed.
266
+ for (const turn of turns) {
267
+ await postJson({
268
+ ...baseFields,
269
+ event_type: 'tool_end',
270
+ primary_model: turn.model,
271
+ tokens_input: turn.tokens_input,
272
+ tokens_output: turn.tokens_output,
273
+ tokens_cache_read: turn.tokens_cache_read,
274
+ tokens_cache_write: turn.tokens_cache_write,
275
+ skills_invoked: turn.skills,
276
+ });
277
+ }
278
+
279
+ if (eventType === 'session_end') {
280
+ // Stop / SubagentStop: the server's session_end branch only finalises
281
+ // an EXISTING session row. If no turns were processed in this hook fire
282
+ // and there's no prior tool_end on this user+repo+day, the row doesn't
283
+ // exist yet and session_end would no-op. Send a bare tool_end first to
284
+ // guarantee the session is created, then finalise.
285
+ if (turns.length === 0) {
286
+ await postJson({ ...baseFields, event_type: 'tool_end' });
287
+ }
288
+ await postJson({ ...baseFields, event_type: 'session_end' });
289
+ } else if (turns.length === 0) {
290
+ // PostToolUse with no new transcript content (e.g. multiple tool calls
291
+ // within the same assistant turn). One keepalive — the server records
292
+ // last_heartbeat_at and bumps total_tool_calls.
293
+ await postJson({ ...baseFields, event_type: eventType });
294
+ }
295
+
296
+ if (eventType === 'session_end') deleteScratch(ccSessionId);
297
+
298
+ process.exit(0);
299
+ })().catch(() => process.exit(0));
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "MCP Server for FCP Launch Coordination System - enables Claude Code to interact with FCP launches and track development time",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "fcp-mcp-server": "./dist/index.js",
9
- "unroo-heartbeat": "./bin/unroo-heartbeat.sh"
9
+ "unroo-heartbeat": "./bin/unroo-heartbeat.js"
10
10
  },
11
11
  "files": [
12
12
  "dist",
package/dist/index.d.ts DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * FCP MCP Server
4
- *
5
- * Provides Claude Code with direct access to FCP Launch Coordination System:
6
- * - Query launches, checklists, and legacy access info
7
- * - Update checklist item status
8
- * - Get project context for migrations
9
- */
10
- export {};