@ahmednawaz/crank 0.1.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.
@@ -0,0 +1,1147 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Crank companion — local bridge between:
5
+ * - browser bookmarklet / panel (WebSocket + HTTP)
6
+ * - Glean agent client (stubbed)
7
+ * - MCP server (HTTP API used by @crank/mcp-server)
8
+ *
9
+ * Pattern adapted from Vizpatch companion/server.js (Express + ws on a fixed port),
10
+ * stripped of CSS/source-map machinery and focused on copy selection + variants.
11
+ */
12
+
13
+ const http = require('http');
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+ const express = require('express');
17
+ const cors = require('cors');
18
+ const WebSocket = require('ws');
19
+
20
+ const store = require('./selection-store');
21
+ const { proposeCopyVariants } = require('./glean-client');
22
+ const { vizpatchAsset, resolveVizpatchRoot } = require('./vizpatch-bridge');
23
+ const { saveTextChange, writeTextChangeAvailable } = require('./text-dispatch');
24
+ const { persistAppliedVariant } = require('./persist-apply');
25
+ const agentQueue = require('./agent-queue');
26
+ const agentRunner = require('./agent-runner');
27
+ const { env } = require('./env');
28
+ const { loadTeamEnvFile } = require('../lib/load-team-env');
29
+ const { describeEnvironment } = require('../lib/resolve-project');
30
+ const { readRuntimeManifest } = require('../lib/urls');
31
+
32
+ function loadEnvFile() {
33
+ const candidates = [
34
+ path.resolve(__dirname, '..', '.env'),
35
+ path.resolve(process.cwd(), '.env')
36
+ ];
37
+ for (const file of candidates) {
38
+ if (!fs.existsSync(file)) {
39
+ continue;
40
+ }
41
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
42
+ for (const line of lines) {
43
+ const trimmed = line.trim();
44
+ if (!trimmed || trimmed.startsWith('#')) {
45
+ continue;
46
+ }
47
+ const eq = trimmed.indexOf('=');
48
+ if (eq < 1) {
49
+ continue;
50
+ }
51
+ const key = trimmed.slice(0, eq).trim();
52
+ let value = trimmed.slice(eq + 1).trim();
53
+ if (
54
+ (value.startsWith('"') && value.endsWith('"')) ||
55
+ (value.startsWith("'") && value.endsWith("'"))
56
+ ) {
57
+ value = value.slice(1, -1);
58
+ }
59
+ if (process.env[key] == null) {
60
+ process.env[key] = value;
61
+ }
62
+ }
63
+ break;
64
+ }
65
+ }
66
+
67
+ // Load .env before resolving PROJECT_ROOT so CRANK_PROJECT_ROOT applies.
68
+ loadTeamEnvFile(path.resolve(__dirname, '..'));
69
+ loadEnvFile();
70
+
71
+ const PORT = Number(env('PORT', '3344'));
72
+ const HOST = env('HOST', '127.0.0.1');
73
+ const PROJECT_ROOT = env('PROJECT_ROOT')
74
+ ? path.resolve(env('PROJECT_ROOT'))
75
+ : path.resolve(__dirname, '..');
76
+
77
+ const app = express();
78
+ app.use(cors({ origin: true }));
79
+ app.use(express.json({ limit: '2mb' }));
80
+
81
+ const {
82
+ teamAuthMiddleware,
83
+ getAuthStatus,
84
+ extractTeamToken,
85
+ isTeamTokenValid,
86
+ teamAuthRequired
87
+ } = require('../lib/team-auth');
88
+
89
+ app.get('/api/auth/status', (_req, res) => {
90
+ res.json({ ok: true, ...getAuthStatus() });
91
+ });
92
+
93
+ app.post('/api/auth/verify', (req, res) => {
94
+ const token = req.body?.token || extractTeamToken(req);
95
+ if (!teamAuthRequired()) {
96
+ res.json({ ok: true, authenticated: true, teamAuthRequired: false });
97
+ return;
98
+ }
99
+ if (isTeamTokenValid(token)) {
100
+ res.json({ ok: true, authenticated: true, teamAuthRequired: true });
101
+ return;
102
+ }
103
+ res.status(401).json({
104
+ ok: false,
105
+ code: 'TEAM_TOKEN_INVALID',
106
+ error: 'Invalid Crank team token'
107
+ });
108
+ });
109
+
110
+ app.use((req, res, next) => {
111
+ if (!req.path.startsWith('/api/')) {
112
+ next();
113
+ return;
114
+ }
115
+ if (req.path === '/api/auth/status' && req.method === 'GET') {
116
+ next();
117
+ return;
118
+ }
119
+ if (req.path === '/api/auth/verify' && req.method === 'POST') {
120
+ next();
121
+ return;
122
+ }
123
+ teamAuthMiddleware(req, res, next);
124
+ });
125
+
126
+ /** @type {Set<import('ws').WebSocket>} */
127
+ const browserClients = new Set();
128
+
129
+ function broadcast(message) {
130
+ let raw;
131
+ try {
132
+ raw = JSON.stringify(message);
133
+ } catch (err) {
134
+ console.error('[Crank] broadcast stringify failed:', err.message);
135
+ return;
136
+ }
137
+ for (const client of browserClients) {
138
+ if (client.readyState === WebSocket.OPEN) {
139
+ try {
140
+ client.send(raw);
141
+ } catch (err) {
142
+ console.error('[Crank] broadcast send failed:', err.message);
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ function mcpConfigPresent() {
149
+ const candidates = [
150
+ path.join(PROJECT_ROOT, '.cursor', 'mcp.json'),
151
+ path.join(PROJECT_ROOT, '.vscode', 'mcp.json')
152
+ ];
153
+ return candidates.some((file) => fs.existsSync(file));
154
+ }
155
+
156
+ function publicCompanionUrl(req) {
157
+ const override = process.env.CRANK_PUBLIC_URL || env('PUBLIC_URL', '');
158
+ if (override) {
159
+ return override.replace(/\/$/, '');
160
+ }
161
+ if (req) {
162
+ const proto = req.get('x-forwarded-proto') || req.protocol || 'http';
163
+ const host = req.get('x-forwarded-host') || req.get('host');
164
+ if (host && !/^127\.0\.0\.1:/.test(host) && !/^localhost:/.test(host)) {
165
+ return `${proto}://${host}`;
166
+ }
167
+ }
168
+ const host = HOST === '0.0.0.0' ? '127.0.0.1' : HOST;
169
+ return `http://${host}:${PORT}`;
170
+ }
171
+
172
+ app.get('/api/runtime', (req, res) => {
173
+ const runtimeFile = path.join(PROJECT_ROOT, '.crank', 'runtime.json');
174
+ if (fs.existsSync(runtimeFile)) {
175
+ try {
176
+ res.json(JSON.parse(fs.readFileSync(runtimeFile, 'utf8')));
177
+ return;
178
+ } catch {
179
+ // fall through
180
+ }
181
+ }
182
+ res.json({
183
+ companion: {
184
+ publicUrl: publicCompanionUrl(req),
185
+ devLoader: `${publicCompanionUrl(req)}/dev-loader.js`,
186
+ bookmarklet: `${publicCompanionUrl(req)}/bookmarklet.js`
187
+ }
188
+ });
189
+ });
190
+
191
+ app.get('/health', (_req, res) => {
192
+ const pending = agentQueue.getPendingAgentTask();
193
+ const status = agentQueue.getAgentQueueStatus();
194
+ res.json({
195
+ ok: true,
196
+ service: 'crank-companion',
197
+ port: PORT,
198
+ host: HOST,
199
+ publicUrl: publicCompanionUrl(_req),
200
+ projectRoot: PROJECT_ROOT,
201
+ ...getAuthStatus(),
202
+ activeSessionId: store.getActiveSession()?.id || null,
203
+ gleanStub: String(process.env.GLEAN_STUB || 'true').toLowerCase() !== 'false',
204
+ vizpatchRoot: resolveVizpatchRoot(),
205
+ textWriter: writeTextChangeAvailable(),
206
+ mcpConfig: mcpConfigPresent(),
207
+ apiKeyConfigured: status.apiKeyConfigured,
208
+ pendingAgentTask: pending
209
+ ? {
210
+ id: pending.id,
211
+ variantId: pending.variantId,
212
+ createdAt: pending.createdAt,
213
+ changeCount: Array.isArray(pending.changes) ? pending.changes.length : 0,
214
+ status: pending.status
215
+ }
216
+ : null,
217
+ latestAgentRun: agentRunner.getLatestAgentRun()
218
+ });
219
+ });
220
+
221
+ app.get('/bookmarklet.js', (_req, res) => {
222
+ const file = path.resolve(__dirname, '..', 'bookmarklet', 'crank-bookmarklet.js');
223
+ res.set({
224
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
225
+ Pragma: 'no-cache',
226
+ Expires: '0'
227
+ });
228
+ res.type('application/javascript');
229
+ res.sendFile(file);
230
+ });
231
+
232
+ app.get('/dev-loader.js', (_req, res) => {
233
+ const file = path.resolve(__dirname, 'dev-loader.js');
234
+ res.set({
235
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
236
+ Pragma: 'no-cache',
237
+ Expires: '0'
238
+ });
239
+ res.type('application/javascript');
240
+ res.sendFile(file);
241
+ });
242
+
243
+ app.get('/ui/vizpatch-ui.css', (_req, res) => {
244
+ const file =
245
+ vizpatchAsset('bookmarklet', 'vizpatch-ui.css') ||
246
+ path.resolve(__dirname, '..', 'bookmarklet', 'vizpatch-ui.css');
247
+ if (!fs.existsSync(file)) {
248
+ res.status(404).type('text/plain').send('vizpatch-ui.css not found — set VIZPATCH_ROOT');
249
+ return;
250
+ }
251
+ res.set({
252
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
253
+ Pragma: 'no-cache',
254
+ Expires: '0'
255
+ });
256
+ res.type('text/css');
257
+ res.sendFile(file);
258
+ });
259
+
260
+ app.get('/ui/text-source.js', (_req, res) => {
261
+ const file = path.resolve(__dirname, '..', 'bookmarklet', 'text-source.js');
262
+ res.set({
263
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
264
+ Pragma: 'no-cache',
265
+ Expires: '0'
266
+ });
267
+ res.type('application/javascript');
268
+ res.sendFile(file);
269
+ });
270
+
271
+ app.get('/ui/crank-ui-helpers.js', (_req, res) => {
272
+ const file = path.resolve(__dirname, '..', 'bookmarklet', 'crank-ui-helpers.js');
273
+ res.set({
274
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
275
+ Pragma: 'no-cache',
276
+ Expires: '0'
277
+ });
278
+ res.type('application/javascript');
279
+ res.sendFile(file);
280
+ });
281
+
282
+ app.post('/api/save-text', (req, res) => {
283
+ const result = saveTextChange(req.body || {}, PROJECT_ROOT);
284
+ if (!result.success) {
285
+ res.status(400).json({ ok: false, ...result });
286
+ return;
287
+ }
288
+ broadcast({
289
+ type: 'TEXT_SAVED',
290
+ payload: result
291
+ });
292
+ res.json({ ok: true, ...result });
293
+ });
294
+
295
+ app.post('/api/preview-text', (req, res) => {
296
+ const sessionId = req.body?.sessionId;
297
+ const nodes = Array.isArray(req.body?.nodes) ? req.body.nodes : [];
298
+ broadcast({
299
+ type: 'PREVIEW_TEXT',
300
+ sessionId,
301
+ nodes,
302
+ preview: true
303
+ });
304
+ res.json({ ok: true });
305
+ });
306
+
307
+ app.get('/', (req, res) => {
308
+ const publicUrl = publicCompanionUrl(req);
309
+ const bookmarkUrl = `${publicUrl}/bookmarklet.js`;
310
+ const mcpPath = path.resolve(__dirname, '..', 'mcp-server', 'index.js');
311
+ const runtimeFile = path.join(PROJECT_ROOT, '.crank', 'runtime.json');
312
+ let mcpEndpoint = '';
313
+ if (fs.existsSync(runtimeFile)) {
314
+ try {
315
+ mcpEndpoint = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'))?.mcp?.endpoint || '';
316
+ } catch {
317
+ mcpEndpoint = '';
318
+ }
319
+ }
320
+ res.type('html').send(`<!doctype html>
321
+ <html lang="en">
322
+ <head>
323
+ <meta charset="utf-8" />
324
+ <title>Crank companion</title>
325
+ <style>
326
+ body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1.25rem; color: #1a1a1a; line-height: 1.5; }
327
+ code { background: #f3f3f3; padding: 0.1em 0.35em; border-radius: 4px; }
328
+ a.bookmarklet { display: inline-block; margin-top: 1rem; padding: 0.55rem 0.9rem; background: #0f766e; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; }
329
+ .muted { color: #555; font-size: 0.95rem; }
330
+ .card { border: 1px solid #ddd; border-radius: 10px; padding: 1rem 1.1rem; margin: 1.25rem 0; background: #fafafa; }
331
+ h2 { font-size: 1rem; margin: 0 0 0.4rem; }
332
+ pre { background: #f3f3f3; padding: 0.75rem 0.9rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; }
333
+ ol { padding-left: 1.25rem; }
334
+ </style>
335
+ </head>
336
+ <body>
337
+ <h1>Crank</h1>
338
+ <p class="muted">One-command copy iteration — select a <code>div</code>, send text to Glean, apply via MCP.</p>
339
+ <p>Companion is running at <code>${publicUrl}</code>.</p>
340
+ <p>Project root: <code>${PROJECT_ROOT}</code></p>
341
+ ${mcpEndpoint ? `<p>Replit MCP endpoint: <code>${mcpEndpoint}</code></p>` : ''}
342
+
343
+ <div class="card">
344
+ <h2>One command</h2>
345
+ <p class="muted" style="margin:0">From your app repo run <code>npx crank</code>. Vite apps auto-load the panel on <code>npm run dev</code>.</p>
346
+ </div>
347
+
348
+ <p>Fallback — drag this to your bookmarks bar (works on any page, any environment):</p>
349
+ <a class="bookmarklet" href="javascript:(function(){var s=document.createElement('script');s.src='${bookmarkUrl}?v='+Date.now();document.documentElement.appendChild(s);})();">Crank</a>
350
+
351
+ <div class="card">
352
+ <h2>Continue in your current Cursor chat</h2>
353
+ <p class="muted">Panel <strong>Agent</strong> queues structured old→new copy changes on this companion and copies <code>Apply the pending Crank change</code> to the clipboard. Paste that in your <strong>current</strong> Cursor chat — the <code>crank-apply-changes</code> skill pulls via MCP. Cursor has no public API to inject into an open Composer; the optional deeplink opens a <em>new</em> chat.</p>
354
+ <p class="muted">Optional fully automatic background run: set <code>CURSOR_API_KEY</code> in <code>.env</code>, then use <strong>Run fully automatic</strong> in the panel (SDK/CLI — separate agent, not your open chat).</p>
355
+ <ol>
356
+ <li class="muted">Run <code>npx crank</code> / init so <code>.cursor/mcp.json</code> and the skill are installed.</li>
357
+ <li class="muted">Reload your MCP servers or restart Cursor after updating the config.</li>
358
+ <li class="muted">In the panel click <strong>Agent</strong>, then paste <code>Apply the pending Crank change</code> in your current Cursor chat (already copied). Skill/tools pull pending changes.</li>
359
+ </ol>
360
+ <pre>{
361
+ "mcpServers": {
362
+ "crank": {
363
+ "command": "node",
364
+ "args": ["${mcpPath}"],
365
+ "env": {
366
+ "CRANK_COMPANION_URL": "${publicUrl}",
367
+ "CRANK_MCP_MODE": "stdio"
368
+ }
369
+ }
370
+ }
371
+ }</pre>
372
+ </div>
373
+
374
+ <p class="muted">Health: <a href="/health">/health</a> · Active selection: <a href="/api/selection">/api/selection</a> · Loader: <a href="/dev-loader.js">/dev-loader.js</a></p>
375
+ </body>
376
+ </html>`);
377
+ });
378
+
379
+ /** Browser / bookmarklet: push a new selection */
380
+ app.post('/api/selection', (req, res) => {
381
+ try {
382
+ const session = store.setSelection(req.body || {});
383
+ broadcast({ type: 'SELECTION_UPDATED', session });
384
+ res.status(201).json({ ok: true, session });
385
+ } catch (err) {
386
+ res.status(400).json({ ok: false, error: err.message });
387
+ }
388
+ });
389
+
390
+ app.get('/api/selection', (req, res) => {
391
+ const session = store.getSession(req.query.sessionId);
392
+ if (!session) {
393
+ res.status(404).json({ ok: false, error: 'No active selection' });
394
+ return;
395
+ }
396
+ res.json({ ok: true, session });
397
+ });
398
+
399
+ app.get('/api/sessions', (_req, res) => {
400
+ res.json({ ok: true, sessions: store.listSessions() });
401
+ });
402
+
403
+ /** Latest Glean progress for a session (HTTP poll while /api/variants blocks). */
404
+ app.get('/api/glean-progress', (req, res) => {
405
+ const sessionId = req.query?.sessionId;
406
+ const session = store.getSession(sessionId);
407
+ if (!session) {
408
+ res.status(404).json({ ok: false, error: 'No active selection' });
409
+ return;
410
+ }
411
+ res.json({
412
+ ok: true,
413
+ sessionId: session.id,
414
+ progress: store.getGleanProgress(session.id),
415
+ updatedAt: session.gleanProgressAt || null
416
+ });
417
+ });
418
+
419
+ /** @type {Set<string>} session ids with an in-flight Glean propose */
420
+ const variantsInFlight = new Set();
421
+
422
+ /** Ask Glean for 3–4 copy variants (waits; also broadcasts progress on WS) */
423
+ app.post('/api/variants', async (req, res) => {
424
+ // Glean wait often takes 60–120s — disable socket timeouts for this route.
425
+ try {
426
+ req.setTimeout?.(0);
427
+ res.setTimeout?.(0);
428
+ if (req.socket) req.socket.setTimeout(0);
429
+ } catch {
430
+ // ignore
431
+ }
432
+
433
+ let lockId = null;
434
+ try {
435
+ const sessionId = req.body?.sessionId;
436
+ const session = store.getSession(sessionId);
437
+ if (!session) {
438
+ res.status(404).json({ ok: false, error: 'No active selection' });
439
+ return;
440
+ }
441
+ if (variantsInFlight.has(session.id)) {
442
+ res.status(409).json({
443
+ ok: false,
444
+ error: 'Glean variants already in progress for this selection'
445
+ });
446
+ return;
447
+ }
448
+ lockId = session.id;
449
+ variantsInFlight.add(lockId);
450
+ console.log(`[Crank] Glean variants requested for session ${session.id}`);
451
+ const variants = await proposeCopyVariants(
452
+ session,
453
+ {
454
+ ...(req.body?.options || {}),
455
+ optionCount: req.body?.optionCount ?? req.body?.options?.optionCount
456
+ },
457
+ (progress) => {
458
+ store.setGleanProgress(session.id, progress);
459
+ broadcast({
460
+ type: 'GLEAN_PROGRESS',
461
+ sessionId: session.id,
462
+ progress
463
+ });
464
+ }
465
+ );
466
+ const updated = store.setVariants(session.id, variants);
467
+ console.log(
468
+ `[Crank] Glean returned ${variants.length} variant(s) for ${session.id}`
469
+ );
470
+ // Respond before WS broadcast so a socket error cannot drop the HTTP body.
471
+ res.json({ ok: true, session: updated, variants });
472
+ try {
473
+ broadcast({ type: 'VARIANTS_READY', session: updated });
474
+ } catch (broadcastErr) {
475
+ console.error('[Crank] VARIANTS_READY broadcast failed:', broadcastErr.message);
476
+ }
477
+ } catch (err) {
478
+ console.error('[Crank] Glean propose failed:', err.message);
479
+ broadcast({
480
+ type: 'GLEAN_ERROR',
481
+ error: err.message
482
+ });
483
+ if (!res.headersSent) {
484
+ res.status(502).json({ ok: false, error: err.message });
485
+ }
486
+ } finally {
487
+ if (lockId) variantsInFlight.delete(lockId);
488
+ }
489
+ });
490
+
491
+ /** Apply a chosen variant (MCP or panel). DOM broadcast + optional source persist. */
492
+ app.post('/api/apply', (req, res) => {
493
+ try {
494
+ const { sessionId, variantId } = req.body || {};
495
+ const persist = req.body?.persist !== false && req.body?.preview !== true;
496
+ const sessionBefore = store.getSession(sessionId);
497
+ if (!sessionBefore) {
498
+ res.status(404).json({ ok: false, error: 'No active selection' });
499
+ return;
500
+ }
501
+ const preApplyNodes = (sessionBefore.nodes || []).map((n) => ({ ...n }));
502
+ const { session, variant } = store.applyVariant(sessionId, variantId);
503
+
504
+ let persistResult = null;
505
+ if (persist) {
506
+ persistResult = persistAppliedVariant(session, variant, PROJECT_ROOT, {
507
+ preApplyNodes
508
+ });
509
+ if (persistResult.persisted) {
510
+ broadcast({ type: 'TEXT_SAVED', payload: persistResult });
511
+ }
512
+ }
513
+
514
+ broadcast({
515
+ type: 'APPLY_VARIANT',
516
+ sessionId: session.id,
517
+ variantId: variant.id,
518
+ nodes: variant.nodes,
519
+ session,
520
+ persist: !!persist,
521
+ persistResult
522
+ });
523
+ res.json({
524
+ ok: true,
525
+ session,
526
+ variant,
527
+ persist: !!persist,
528
+ persistResult
529
+ });
530
+ } catch (err) {
531
+ res.status(400).json({ ok: false, error: err.message });
532
+ }
533
+ });
534
+
535
+ /** Preview-only apply — DOM only, never writes source. */
536
+ app.post('/api/preview-variant', (req, res) => {
537
+ try {
538
+ const { sessionId, variantId } = req.body || {};
539
+ const session = store.getSession(sessionId);
540
+ if (!session) {
541
+ res.status(404).json({ ok: false, error: 'No active selection' });
542
+ return;
543
+ }
544
+ const variant = (session.variants || []).find((v) => v.id === variantId);
545
+ if (!variant) {
546
+ res.status(404).json({ ok: false, error: `Unknown variant: ${variantId}` });
547
+ return;
548
+ }
549
+ broadcast({
550
+ type: 'APPLY_VARIANT',
551
+ sessionId: session.id,
552
+ variantId: variant.id,
553
+ nodes: variant.nodes,
554
+ session,
555
+ persist: false,
556
+ preview: true
557
+ });
558
+ res.json({ ok: true, session, variant, persist: false });
559
+ } catch (err) {
560
+ res.status(400).json({ ok: false, error: err.message });
561
+ }
562
+ });
563
+
564
+ /** Queue structured pending copy changes (Retune-parity; does not save). */
565
+ app.post('/api/agent-prompt', (req, res) => {
566
+ try {
567
+ const { sessionId, variantId, source } = req.body || {};
568
+ const session = store.getSession(sessionId);
569
+ if (!session) {
570
+ res.status(404).json({ ok: false, error: 'No active selection' });
571
+ return;
572
+ }
573
+ const variant = (session.variants || []).find((v) => v.id === variantId);
574
+ if (!variant) {
575
+ res.status(404).json({ ok: false, error: `Unknown variant: ${variantId}` });
576
+ return;
577
+ }
578
+ const task = agentQueue.enqueueAgentTask({
579
+ session,
580
+ variant,
581
+ source: source || 'panel'
582
+ });
583
+ const publicTask = agentQueue.publicPending(task);
584
+ broadcast({
585
+ type: 'AGENT_PROMPT_QUEUED',
586
+ sessionId: session.id,
587
+ variantId: variant.id,
588
+ task: publicTask
589
+ });
590
+ res.status(201).json({
591
+ ok: true,
592
+ queued: true,
593
+ task: publicTask,
594
+ status: agentQueue.getAgentQueueStatus(),
595
+ howto:
596
+ 'Queued for same-chat handoff. Paste “Apply the pending Crank change” in your current Cursor chat (panel copies it). Skill pulls via crank_get_formatted_changes. Optional: Open new chat instead (deeplink) or set CURSOR_API_KEY + POST /api/agent-run for fully automatic.'
597
+ });
598
+ } catch (err) {
599
+ res.status(400).json({ ok: false, error: err.message });
600
+ }
601
+ });
602
+
603
+ app.get('/api/agent-prompt', (_req, res) => {
604
+ const task = agentQueue.getPendingAgentTask();
605
+ if (!task) {
606
+ res.status(404).json({ ok: false, error: 'No pending agent prompt' });
607
+ return;
608
+ }
609
+ res.json({ ok: true, task });
610
+ });
611
+
612
+ app.post('/api/agent-prompt/ack', (req, res) => {
613
+ const result = agentQueue.ackPendingAgentTask(req.body?.id);
614
+ if (!result.ok) {
615
+ res.status(404).json(result);
616
+ return;
617
+ }
618
+ broadcast({ type: 'AGENT_PROMPT_ACKED', task: result.task });
619
+ res.json(result);
620
+ });
621
+
622
+ app.delete('/api/agent-prompt', (_req, res) => {
623
+ const result = agentQueue.clearPendingAgentTask();
624
+ broadcast({ type: 'AGENT_PROMPT_CLEARED', task: result.task });
625
+ res.json(result);
626
+ });
627
+
628
+ /** Retune-parity pending change APIs */
629
+ app.get('/api/pending-changes', (req, res) => {
630
+ const enriched =
631
+ String(req.query.enriched || '').toLowerCase() === 'true' ||
632
+ req.query.enriched === '1';
633
+ const changes = agentQueue.getPendingChanges({ enriched });
634
+ if (!changes) {
635
+ res.status(404).json({ ok: false, error: 'No pending Crank copy changes' });
636
+ return;
637
+ }
638
+ res.json({ ok: true, pending: changes });
639
+ });
640
+
641
+ app.get('/api/pending-changes/formatted', (req, res) => {
642
+ const clear =
643
+ req.query.clear == null
644
+ ? true
645
+ : String(req.query.clear).toLowerCase() !== 'false' &&
646
+ req.query.clear !== '0';
647
+ const fidelity = req.query.fidelity || 'standard';
648
+ const result = agentQueue.getFormattedChanges({ fidelity, clear });
649
+ if (!result.ok) {
650
+ res.status(404).json(result);
651
+ return;
652
+ }
653
+ if (result.cleared) {
654
+ broadcast({ type: 'AGENT_PROMPT_CLEARED', task: result.task });
655
+ }
656
+ res.json(result);
657
+ });
658
+
659
+ app.get('/api/pending-changes/watch', async (req, res) => {
660
+ const timeoutMs = Number(req.query.timeoutMs) || 30000;
661
+ const sinceId = req.query.sinceId || null;
662
+ const result = await agentQueue.watchPendingChanges({ timeoutMs, sinceId });
663
+ res.json(result);
664
+ });
665
+
666
+ app.delete('/api/pending-changes', (_req, res) => {
667
+ const result = agentQueue.clearPendingAgentTask();
668
+ broadcast({ type: 'AGENT_PROMPT_CLEARED', task: result.task });
669
+ res.json(result);
670
+ });
671
+
672
+ app.get('/api/agent-status', (_req, res) => {
673
+ res.json({
674
+ ...agentQueue.getAgentQueueStatus(),
675
+ latestRun: agentRunner.getLatestAgentRun()
676
+ });
677
+ });
678
+
679
+ /** Retune-style: tell the agent which repo, environment, and URLs are active. */
680
+ app.get('/api/environment', (req, res) => {
681
+ const runtimeFile = path.join(PROJECT_ROOT, '.crank', 'runtime.json');
682
+ let runtime = null;
683
+ if (fs.existsSync(runtimeFile)) {
684
+ try {
685
+ runtime = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'));
686
+ } catch {
687
+ runtime = null;
688
+ }
689
+ }
690
+ const resolved = describeEnvironment(PROJECT_ROOT);
691
+ res.json({
692
+ ok: true,
693
+ projectRoot: PROJECT_ROOT,
694
+ resolved,
695
+ companion: {
696
+ publicUrl: publicCompanionUrl(req),
697
+ localUrl: `http://127.0.0.1:${PORT}`,
698
+ healthy: true
699
+ },
700
+ runtime,
701
+ mcp: runtime?.mcp || null,
702
+ agent: {
703
+ pastePhrase: 'Apply the pending Crank change',
704
+ startCommand: 'npx crank',
705
+ setupCommand: 'npx crank setup'
706
+ },
707
+ auth: getAuthStatus(),
708
+ pending: agentQueue.getAgentQueueStatus()
709
+ });
710
+ });
711
+
712
+ /**
713
+ * Optional secondary path: headless Cursor SDK/CLI when CURSOR_API_KEY is set.
714
+ * Primary UX remains MCP + skill pull (Retune-parity).
715
+ */
716
+ app.post('/api/agent-run', (req, res) => {
717
+ try {
718
+ let task = agentQueue.getPendingAgentTask();
719
+ const { sessionId, variantId } = req.body || {};
720
+ if (!task && sessionId && variantId) {
721
+ const session = store.getSession(sessionId);
722
+ if (!session) {
723
+ res.status(404).json({ ok: false, error: 'No active selection' });
724
+ return;
725
+ }
726
+ const variant = (session.variants || []).find((v) => v.id === variantId);
727
+ if (!variant) {
728
+ res.status(404).json({ ok: false, error: `Unknown variant: ${variantId}` });
729
+ return;
730
+ }
731
+ task = agentQueue.enqueueAgentTask({
732
+ session,
733
+ variant,
734
+ source: 'panel-auto'
735
+ });
736
+ broadcast({
737
+ type: 'AGENT_PROMPT_QUEUED',
738
+ sessionId: session.id,
739
+ variantId: variant.id,
740
+ task: agentQueue.publicPending(task)
741
+ });
742
+ }
743
+ if (!task) {
744
+ res.status(404).json({
745
+ ok: false,
746
+ error: 'No pending Crank changes — click Agent first or pass sessionId+variantId'
747
+ });
748
+ return;
749
+ }
750
+ if (!agentRunner.apiKeyConfigured()) {
751
+ res.status(400).json({
752
+ ok: false,
753
+ error: 'Set CURSOR_API_KEY to enable one-click Agent apply',
754
+ apiKeyConfigured: false
755
+ });
756
+ return;
757
+ }
758
+ const job = agentRunner.startAgentRun({
759
+ task,
760
+ projectRoot: PROJECT_ROOT,
761
+ onUpdate: (j) => {
762
+ broadcast({ type: 'AGENT_RUN_UPDATE', job: j });
763
+ }
764
+ });
765
+ broadcast({ type: 'AGENT_RUN_UPDATE', job });
766
+ res.status(202).json({
767
+ ok: true,
768
+ job,
769
+ task: agentQueue.publicPending(task)
770
+ });
771
+ } catch (err) {
772
+ const status = err.code === 'MISSING_API_KEY' ? 400 : 500;
773
+ res.status(status).json({
774
+ ok: false,
775
+ error: err.message,
776
+ apiKeyConfigured: agentRunner.apiKeyConfigured()
777
+ });
778
+ }
779
+ });
780
+
781
+ app.get('/api/agent-run/:id', (req, res) => {
782
+ const job = agentRunner.getAgentRun(req.params.id);
783
+ if (!job) {
784
+ res.status(404).json({ ok: false, error: 'Unknown agent run' });
785
+ return;
786
+ }
787
+ res.json({ ok: true, job });
788
+ });
789
+
790
+ app.get('/api/agent-run', (_req, res) => {
791
+ const job = agentRunner.getLatestAgentRun();
792
+ if (!job) {
793
+ res.status(404).json({ ok: false, error: 'No agent runs yet' });
794
+ return;
795
+ }
796
+ res.json({ ok: true, job });
797
+ });
798
+
799
+ /** Optional: push arbitrary node text updates (edited by agent before apply) */
800
+ app.post('/api/apply-nodes', (req, res) => {
801
+ try {
802
+ const session = store.getSession(req.body?.sessionId);
803
+ if (!session) {
804
+ res.status(404).json({ ok: false, error: 'No active selection' });
805
+ return;
806
+ }
807
+ const nodes = Array.isArray(req.body?.nodes) ? req.body.nodes : [];
808
+ const byPath = new Map(nodes.map((n) => [String(n.path), n]));
809
+ session.nodes = session.nodes.map((node) => {
810
+ const next = byPath.get(String(node.path));
811
+ return next
812
+ ? {
813
+ ...node,
814
+ text: next.text,
815
+ previousText: node.text,
816
+ originalText:
817
+ node.originalText != null ? node.originalText : node.text
818
+ }
819
+ : node;
820
+ });
821
+ session.combinedText = session.nodes.map((n) => n.text).join('\n');
822
+ session.appliedVariantId = req.body?.variantId || 'custom';
823
+ session.updatedAt = new Date().toISOString();
824
+ broadcast({
825
+ type: 'APPLY_VARIANT',
826
+ sessionId: session.id,
827
+ variantId: session.appliedVariantId,
828
+ nodes: session.nodes,
829
+ session
830
+ });
831
+ res.json({ ok: true, session });
832
+ } catch (err) {
833
+ res.status(400).json({ ok: false, error: err.message });
834
+ }
835
+ });
836
+
837
+ const server = http.createServer(app);
838
+ const wss = new WebSocket.Server({
839
+ server,
840
+ path: '/ws',
841
+ verifyClient(info, cb) {
842
+ if (!teamAuthRequired()) {
843
+ cb(true);
844
+ return;
845
+ }
846
+ cb(isTeamTokenValid(extractTeamToken(info.req)));
847
+ }
848
+ });
849
+
850
+ wss.on('connection', (socket, req) => {
851
+ if (teamAuthRequired() && !isTeamTokenValid(extractTeamToken(req))) {
852
+ try {
853
+ socket.send(
854
+ JSON.stringify({
855
+ type: 'ERROR',
856
+ code: 'TEAM_TOKEN_REQUIRED',
857
+ error: 'Crank team token required'
858
+ })
859
+ );
860
+ } catch {
861
+ // ignore
862
+ }
863
+ socket.close(4401, 'Team token required');
864
+ return;
865
+ }
866
+ browserClients.add(socket);
867
+ socket.send(
868
+ JSON.stringify({
869
+ type: 'HELLO',
870
+ service: 'crank-companion',
871
+ activeSession: store.getActiveSession()
872
+ })
873
+ );
874
+
875
+ socket.on('message', async (raw) => {
876
+ let message;
877
+ try {
878
+ message = JSON.parse(String(raw));
879
+ } catch {
880
+ return;
881
+ }
882
+
883
+ try {
884
+ if (message.type === 'SET_SELECTION') {
885
+ const session = store.setSelection(message.payload || {});
886
+ broadcast({ type: 'SELECTION_UPDATED', session });
887
+ socket.send(JSON.stringify({ type: 'SELECTION_ACK', session }));
888
+ return;
889
+ }
890
+
891
+ if (message.type === 'REQUEST_VARIANTS') {
892
+ const session = store.getSession(message.sessionId);
893
+ if (!session) {
894
+ socket.send(JSON.stringify({ type: 'ERROR', error: 'No active selection' }));
895
+ return;
896
+ }
897
+ if (variantsInFlight.has(session.id)) {
898
+ socket.send(
899
+ JSON.stringify({
900
+ type: 'ERROR',
901
+ error: 'Glean variants already in progress for this selection'
902
+ })
903
+ );
904
+ return;
905
+ }
906
+ variantsInFlight.add(session.id);
907
+ try {
908
+ const variants = await proposeCopyVariants(
909
+ session,
910
+ message.options || {},
911
+ (progress) => {
912
+ store.setGleanProgress(session.id, progress);
913
+ broadcast({
914
+ type: 'GLEAN_PROGRESS',
915
+ sessionId: session.id,
916
+ progress
917
+ });
918
+ }
919
+ );
920
+ const updated = store.setVariants(session.id, variants);
921
+ broadcast({ type: 'VARIANTS_READY', session: updated });
922
+ } catch (err) {
923
+ broadcast({ type: 'GLEAN_ERROR', error: err.message });
924
+ socket.send(JSON.stringify({ type: 'ERROR', error: err.message }));
925
+ } finally {
926
+ variantsInFlight.delete(session.id);
927
+ }
928
+ return;
929
+ }
930
+
931
+ if (message.type === 'APPLY_VARIANT') {
932
+ const persist =
933
+ message.persist !== false && message.preview !== true;
934
+ const sessionBefore = store.getSession(message.sessionId);
935
+ if (!sessionBefore) {
936
+ socket.send(
937
+ JSON.stringify({ type: 'ERROR', error: 'No active selection' })
938
+ );
939
+ return;
940
+ }
941
+ const preApplyNodes = (sessionBefore.nodes || []).map((n) => ({ ...n }));
942
+ const { session, variant } = store.applyVariant(
943
+ message.sessionId,
944
+ message.variantId
945
+ );
946
+ let persistResult = null;
947
+ if (persist) {
948
+ persistResult = persistAppliedVariant(session, variant, PROJECT_ROOT, {
949
+ preApplyNodes
950
+ });
951
+ if (persistResult.persisted) {
952
+ broadcast({ type: 'TEXT_SAVED', payload: persistResult });
953
+ }
954
+ }
955
+ broadcast({
956
+ type: 'APPLY_VARIANT',
957
+ sessionId: session.id,
958
+ variantId: variant.id,
959
+ nodes: variant.nodes,
960
+ session,
961
+ persist: !!persist,
962
+ persistResult
963
+ });
964
+ socket.send(
965
+ JSON.stringify({
966
+ type: 'APPLY_RESULT',
967
+ ok: true,
968
+ persist: !!persist,
969
+ persistResult,
970
+ variantId: variant.id
971
+ })
972
+ );
973
+ return;
974
+ }
975
+
976
+ if (message.type === 'PREVIEW_VARIANT') {
977
+ const session = store.getSession(message.sessionId);
978
+ if (!session) {
979
+ socket.send(
980
+ JSON.stringify({ type: 'ERROR', error: 'No active selection' })
981
+ );
982
+ return;
983
+ }
984
+ const variant = (session.variants || []).find(
985
+ (v) => v.id === message.variantId
986
+ );
987
+ if (!variant) {
988
+ socket.send(
989
+ JSON.stringify({
990
+ type: 'ERROR',
991
+ error: `Unknown variant: ${message.variantId}`
992
+ })
993
+ );
994
+ return;
995
+ }
996
+ broadcast({
997
+ type: 'APPLY_VARIANT',
998
+ sessionId: session.id,
999
+ variantId: variant.id,
1000
+ nodes: variant.nodes,
1001
+ session,
1002
+ persist: false,
1003
+ preview: true
1004
+ });
1005
+ socket.send(
1006
+ JSON.stringify({
1007
+ type: 'APPLY_RESULT',
1008
+ ok: true,
1009
+ persist: false,
1010
+ preview: true,
1011
+ variantId: variant.id
1012
+ })
1013
+ );
1014
+ return;
1015
+ }
1016
+
1017
+ if (message.type === 'QUEUE_AGENT_PROMPT') {
1018
+ const session = store.getSession(message.sessionId);
1019
+ if (!session) {
1020
+ socket.send(
1021
+ JSON.stringify({ type: 'ERROR', error: 'No active selection' })
1022
+ );
1023
+ return;
1024
+ }
1025
+ const variant = (session.variants || []).find(
1026
+ (v) => v.id === message.variantId
1027
+ );
1028
+ if (!variant) {
1029
+ socket.send(
1030
+ JSON.stringify({
1031
+ type: 'ERROR',
1032
+ error: `Unknown variant: ${message.variantId}`
1033
+ })
1034
+ );
1035
+ return;
1036
+ }
1037
+ const task = agentQueue.enqueueAgentTask({
1038
+ session,
1039
+ variant,
1040
+ source: message.source || 'panel-ws'
1041
+ });
1042
+ const publicTask = agentQueue.publicPending(task);
1043
+ broadcast({
1044
+ type: 'AGENT_PROMPT_QUEUED',
1045
+ sessionId: session.id,
1046
+ variantId: variant.id,
1047
+ task: publicTask
1048
+ });
1049
+ socket.send(
1050
+ JSON.stringify({
1051
+ type: 'AGENT_PROMPT_QUEUED',
1052
+ ok: true,
1053
+ task: publicTask,
1054
+ howto:
1055
+ 'Queued for same-chat handoff. Paste “Apply the pending Crank change” in your current Cursor chat — skill pulls via crank_get_formatted_changes.'
1056
+ })
1057
+ );
1058
+ return;
1059
+ }
1060
+
1061
+ if (message.type === 'PREVIEW_TEXT_CHANGE') {
1062
+ broadcast({
1063
+ type: 'PREVIEW_TEXT',
1064
+ sessionId: message.sessionId || message.payload?.sessionId,
1065
+ nodes: message.payload?.nodes || message.nodes || [],
1066
+ preview: true
1067
+ });
1068
+ socket.send(JSON.stringify({ type: 'PREVIEW_ACK', ok: true }));
1069
+ return;
1070
+ }
1071
+
1072
+ if (message.type === 'SAVE_TEXT_CHANGE') {
1073
+ const result = saveTextChange(message.payload || {}, PROJECT_ROOT);
1074
+ socket.send(
1075
+ JSON.stringify({
1076
+ type: 'SAVE_RESULT',
1077
+ payload: result
1078
+ })
1079
+ );
1080
+ if (result.success) {
1081
+ broadcast({ type: 'TEXT_SAVED', payload: result });
1082
+ }
1083
+ return;
1084
+ }
1085
+
1086
+ if (message.type === 'DISCARD_TEXT_PREVIEW') {
1087
+ broadcast({
1088
+ type: 'DISCARD_TEXT_PREVIEW',
1089
+ sessionId: message.sessionId || message.payload?.sessionId
1090
+ });
1091
+ return;
1092
+ }
1093
+ } catch (err) {
1094
+ socket.send(JSON.stringify({ type: 'ERROR', error: err.message }));
1095
+ }
1096
+ });
1097
+
1098
+ socket.on('close', () => {
1099
+ browserClients.delete(socket);
1100
+ });
1101
+ });
1102
+
1103
+ server.requestTimeout = 600000; // 10 min — Glean wait can take ~2 min
1104
+ server.headersTimeout = 610000;
1105
+ server.timeout = 600000;
1106
+ server.keepAliveTimeout = 120000;
1107
+
1108
+ server.on('error', (err) => {
1109
+ if (err && err.code === 'EADDRINUSE') {
1110
+ console.error(
1111
+ `[Crank] Port ${PORT} already in use — companion may already be running at http://${HOST}:${PORT}`
1112
+ );
1113
+ // Exit 0 so supervisors treat this as "already up", not a crash loop.
1114
+ process.exit(0);
1115
+ }
1116
+ console.error('[Crank] HTTP server error:', err);
1117
+ process.exit(1);
1118
+ });
1119
+
1120
+ server.listen(PORT, HOST, () => {
1121
+ console.log(`[Crank] Companion listening on http://${HOST}:${PORT}`);
1122
+ console.log(`[Crank] WebSocket ws://${HOST}:${PORT}/ws`);
1123
+ console.log(`[Crank] Project root: ${PROJECT_ROOT}`);
1124
+ console.log(
1125
+ `[Crank] Glean: ${
1126
+ String(process.env.GLEAN_STUB || 'true').toLowerCase() !== 'false'
1127
+ ? 'STUB mode'
1128
+ : process.env.GLEAN_BASE_URL ||
1129
+ process.env.GLEAN_API_URL ||
1130
+ '(missing GLEAN_BASE_URL)'
1131
+ }`
1132
+ );
1133
+ console.log(
1134
+ `[Crank] Vizpatch bridge: ${resolveVizpatchRoot() || 'NOT FOUND'} (text-writer ${
1135
+ writeTextChangeAvailable() ? 'ok' : 'missing'
1136
+ })`
1137
+ );
1138
+ });
1139
+
1140
+ // Keep the process alive on non-fatal async errors. Agents previously saw the
1141
+ // companion vanish when an uncaughtException (or listen race) took down Node.
1142
+ process.on('uncaughtException', (err) => {
1143
+ console.error('[Crank] uncaughtException (process kept alive):', err);
1144
+ });
1145
+ process.on('unhandledRejection', (err) => {
1146
+ console.error('[Crank] unhandledRejection (process kept alive):', err);
1147
+ });