@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.
- package/.env.example +53 -0
- package/README.md +266 -0
- package/bin/crank.js +737 -0
- package/bookmarklet/crank-bookmarklet.js +2295 -0
- package/bookmarklet/crank-ui-helpers.js +355 -0
- package/bookmarklet/install-snippet.txt +5 -0
- package/bookmarklet/text-source.js +239 -0
- package/companion/agent-queue.js +485 -0
- package/companion/agent-runner.js +334 -0
- package/companion/bin/crank.js +69 -0
- package/companion/dev-loader.js +39 -0
- package/companion/glean-client.js +991 -0
- package/companion/package.json +18 -0
- package/companion/persist-apply.js +189 -0
- package/companion/selection-store.js +175 -0
- package/companion/server.js +1147 -0
- package/companion/text-dispatch.js +419 -0
- package/companion/vizpatch-bridge.js +49 -0
- package/lib/copy-labels.js +86 -0
- package/lib/detect.js +88 -0
- package/lib/env.js +23 -0
- package/lib/init.js +435 -0
- package/lib/load-team-env.js +43 -0
- package/lib/resolve-project.js +203 -0
- package/lib/team-auth.js +82 -0
- package/lib/urls.js +164 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +16 -0
- package/mcp-server/tools.js +480 -0
- package/package.json +57 -0
- package/panel/demo.html +62 -0
- package/skill/SKILL.md +60 -0
- package/skills/README.md +16 -0
- package/skills/copy-guidance.md +25 -0
- package/team.env +4 -0
- package/vite.js +75 -0
|
@@ -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
|
+
});
|