linear-grab-bridge 0.9.0 → 0.11.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.
- package/linear-grab-bridge.mjs +122 -5
- package/package.json +1 -1
package/linear-grab-bridge.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Binds 127.0.0.1 only — never exposed to the network.
|
|
14
14
|
*/
|
|
15
15
|
import { createServer } from 'node:http';
|
|
16
|
-
import { spawn } from 'node:child_process';
|
|
16
|
+
import { spawn, execFile } from 'node:child_process';
|
|
17
17
|
import { randomUUID } from 'node:crypto';
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -28,7 +28,37 @@ const flag = (name, fallback) => {
|
|
|
28
28
|
const PORT = Number(flag('--port', '4577'));
|
|
29
29
|
const DIR = flag('--dir', process.cwd());
|
|
30
30
|
const CLAUDE_BIN = flag('--claude', 'claude');
|
|
31
|
-
const VERSION = '0.
|
|
31
|
+
const VERSION = '0.10.0';
|
|
32
|
+
|
|
33
|
+
/** Best-effort command runner (git/gh introspection). Never throws. */
|
|
34
|
+
function run(cmd, args, cwd = DIR) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
execFile(cmd, args, { cwd, timeout: 15_000, maxBuffer: 2_000_000 }, (err, stdout) =>
|
|
37
|
+
resolve(err ? null : stdout.toString()),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Opt-in isolation: give a task its own git worktree + branch so parallel
|
|
43
|
+
local agents never trample each other's working tree. */
|
|
44
|
+
async function setupWorktree(task) {
|
|
45
|
+
const base = join(
|
|
46
|
+
HISTORY_DIR,
|
|
47
|
+
'worktrees',
|
|
48
|
+
createHash('sha1').update(DIR).digest('hex').slice(0, 8),
|
|
49
|
+
);
|
|
50
|
+
mkdirSync(base, { recursive: true });
|
|
51
|
+
const path = join(base, task.id);
|
|
52
|
+
const slug = task.title
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
55
|
+
.replace(/^-+|-+$/g, '')
|
|
56
|
+
.slice(0, 32);
|
|
57
|
+
const branch = `lg/${slug || 'task'}-${task.id}`;
|
|
58
|
+
const out = await run('git', ['worktree', 'add', path, '-b', branch]);
|
|
59
|
+
if (out === null) return null;
|
|
60
|
+
return { path, branch, removed: false };
|
|
61
|
+
}
|
|
32
62
|
const MAX_TAIL = 300;
|
|
33
63
|
const IDLE_KILL_MS = 30 * 60_000; // free an idle interactive session after 30min (resumable)
|
|
34
64
|
|
|
@@ -76,6 +106,10 @@ function saveHistory() {
|
|
|
76
106
|
sessionId: t.sessionId ?? null,
|
|
77
107
|
model: t.model ?? null,
|
|
78
108
|
usage: t.usage ?? null,
|
|
109
|
+
startCommit: t.startCommit ?? null,
|
|
110
|
+
subagents: t.subagents ?? 0,
|
|
111
|
+
worktree: t.worktree ?? null,
|
|
112
|
+
cwd: t.cwd ?? null,
|
|
79
113
|
tail: (t.tail ?? []).slice(-60),
|
|
80
114
|
}));
|
|
81
115
|
writeFileSync(HISTORY_FILE, JSON.stringify(items));
|
|
@@ -134,6 +168,7 @@ function summary(t) {
|
|
|
134
168
|
usage: t.usage ?? null,
|
|
135
169
|
subagents: t.subagents ?? 0,
|
|
136
170
|
permissionMode: t.permissionMode ?? 'acceptEdits',
|
|
171
|
+
worktree: t.worktree ?? null,
|
|
137
172
|
};
|
|
138
173
|
}
|
|
139
174
|
|
|
@@ -169,7 +204,7 @@ function startProcess(task, { resume, initialText }) {
|
|
|
169
204
|
if (resume) args.push('--resume', resume);
|
|
170
205
|
|
|
171
206
|
const child = spawn(CLAUDE_BIN, args, {
|
|
172
|
-
cwd: DIR,
|
|
207
|
+
cwd: task.cwd ?? DIR,
|
|
173
208
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
174
209
|
env: { ...process.env, ...(task.env ?? {}) },
|
|
175
210
|
});
|
|
@@ -288,7 +323,7 @@ function ingest(task, event) {
|
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
|
|
291
|
-
function startTask({ title, prompt, model, env, permissionMode }) {
|
|
326
|
+
async function startTask({ title, prompt, model, env, permissionMode, worktree }) {
|
|
292
327
|
const id = randomUUID().slice(0, 8);
|
|
293
328
|
const task = {
|
|
294
329
|
id,
|
|
@@ -314,10 +349,75 @@ function startTask({ title, prompt, model, env, permissionMode }) {
|
|
|
314
349
|
};
|
|
315
350
|
tasks.set(id, task);
|
|
316
351
|
pushTail(task, 'user', String(prompt ?? '').slice(0, 2000));
|
|
352
|
+
if (worktree) {
|
|
353
|
+
const wt = await setupWorktree(task);
|
|
354
|
+
if (wt) {
|
|
355
|
+
task.worktree = wt;
|
|
356
|
+
task.cwd = wt.path;
|
|
357
|
+
pushTail(task, 'tool', `⎇ isolated worktree: ${wt.branch} @ ${wt.path}`);
|
|
358
|
+
} else {
|
|
359
|
+
pushTail(task, 'stderr', 'Worktree setup failed — running in the main working tree.');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Snapshot HEAD so the Changes view can attribute work to this task.
|
|
363
|
+
task.startCommit = ((await run('git', ['rev-parse', 'HEAD'], task.cwd ?? DIR)) ?? '').trim() || null;
|
|
317
364
|
startProcess(task, { initialText: String(prompt ?? '') });
|
|
318
365
|
return task;
|
|
319
366
|
}
|
|
320
367
|
|
|
368
|
+
/** What this task changed: files (+/−), branch, untracked, matching PRs. */
|
|
369
|
+
async function computeDiff(task) {
|
|
370
|
+
const cwd = task.cwd ?? DIR;
|
|
371
|
+
const branch = (await run('git', ['branch', '--show-current'], cwd))?.trim() ?? '';
|
|
372
|
+
const base = task.startCommit;
|
|
373
|
+
const numstat = (await run('git', ['diff', '--numstat', ...(base ? [base] : [])], cwd)) ?? '';
|
|
374
|
+
const files = numstat
|
|
375
|
+
.split('\n')
|
|
376
|
+
.filter(Boolean)
|
|
377
|
+
.map((line) => {
|
|
378
|
+
const [a, d, ...path] = line.split('\t');
|
|
379
|
+
return {
|
|
380
|
+
path: path.join('\t'),
|
|
381
|
+
added: a === '-' ? 0 : Number(a),
|
|
382
|
+
deleted: d === '-' ? 0 : Number(d),
|
|
383
|
+
binary: a === '-',
|
|
384
|
+
};
|
|
385
|
+
})
|
|
386
|
+
.filter((f) => f.path);
|
|
387
|
+
const untracked = ((await run('git', ['status', '--porcelain'], cwd)) ?? '')
|
|
388
|
+
.split('\n')
|
|
389
|
+
.filter((l) => l.startsWith('??'))
|
|
390
|
+
.map((l) => l.slice(3).trim())
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.slice(0, 40);
|
|
393
|
+
|
|
394
|
+
// PRs: by current head branch AND by the issue identifier in the title.
|
|
395
|
+
const prs = new Map();
|
|
396
|
+
const ident = task.title.match(/^([A-Z][A-Z0-9]*-\d+)/)?.[1];
|
|
397
|
+
for (const args of [
|
|
398
|
+
branch ? ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'url,title,state', '--limit', '3'] : null,
|
|
399
|
+
ident ? ['pr', 'list', '--search', ident, '--state', 'all', '--json', 'url,title,state', '--limit', '3'] : null,
|
|
400
|
+
]) {
|
|
401
|
+
if (!args) continue;
|
|
402
|
+
try {
|
|
403
|
+
const out = await run('gh', args, cwd);
|
|
404
|
+
for (const pr of JSON.parse(out ?? '[]')) prs.set(pr.url, pr);
|
|
405
|
+
} catch {
|
|
406
|
+
/* gh missing or not a repo with remote */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
branch,
|
|
412
|
+
baseCommit: base ?? null,
|
|
413
|
+
files: files.slice(0, 60),
|
|
414
|
+
untracked,
|
|
415
|
+
totalAdded: files.reduce((n, f) => n + f.added, 0),
|
|
416
|
+
totalDeleted: files.reduce((n, f) => n + f.deleted, 0),
|
|
417
|
+
prs: [...prs.values()],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
321
421
|
/** Send a follow-up. Respawns via --resume when the process is gone or a model
|
|
322
422
|
change is pending — that's also how model switches take effect. */
|
|
323
423
|
function sendMessage(task, text) {
|
|
@@ -374,6 +474,12 @@ createServer(async (req, res) => {
|
|
|
374
474
|
? json(res, 200, { ...summary(t), tail: (t.tail ?? []).slice(-120) })
|
|
375
475
|
: json(res, 404, { error: 'not found' });
|
|
376
476
|
}
|
|
477
|
+
const diff = url.pathname.match(/^\/tasks\/([\w-]+)\/diff$/);
|
|
478
|
+
if (req.method === 'GET' && diff) {
|
|
479
|
+
const t = tasks.get(diff[1]);
|
|
480
|
+
if (!t) return json(res, 404, { error: 'not found' });
|
|
481
|
+
return json(res, 200, await computeDiff(t));
|
|
482
|
+
}
|
|
377
483
|
const stop = url.pathname.match(/^\/tasks\/([\w-]+)\/stop$/);
|
|
378
484
|
if (req.method === 'POST' && stop) {
|
|
379
485
|
const t = tasks.get(stop[1]);
|
|
@@ -393,6 +499,17 @@ createServer(async (req, res) => {
|
|
|
393
499
|
}
|
|
394
500
|
return json(res, 200, { ok: true });
|
|
395
501
|
}
|
|
502
|
+
const wtRemove = url.pathname.match(/^\/tasks\/([\w-]+)\/worktree\/remove$/);
|
|
503
|
+
if (req.method === 'POST' && wtRemove) {
|
|
504
|
+
const t = tasks.get(wtRemove[1]);
|
|
505
|
+
if (!t?.worktree || t.worktree.removed) return json(res, 404, { error: 'no worktree' });
|
|
506
|
+
if (t.status === 'running') return json(res, 409, { error: 'task still running' });
|
|
507
|
+
await run('git', ['worktree', 'remove', '--force', t.worktree.path]);
|
|
508
|
+
t.worktree.removed = true;
|
|
509
|
+
t.cwd = null;
|
|
510
|
+
saveHistory();
|
|
511
|
+
return json(res, 200, summary(t));
|
|
512
|
+
}
|
|
396
513
|
const message = url.pathname.match(/^\/tasks\/([\w-]+)\/message$/);
|
|
397
514
|
if (req.method === 'POST' && message) {
|
|
398
515
|
const t = tasks.get(message[1]);
|
|
@@ -424,7 +541,7 @@ createServer(async (req, res) => {
|
|
|
424
541
|
if (req.method === 'POST' && url.pathname === '/tasks') {
|
|
425
542
|
const body = await readBody(req);
|
|
426
543
|
if (!body.prompt) return json(res, 400, { error: 'prompt required' });
|
|
427
|
-
const task = startTask(body);
|
|
544
|
+
const task = await startTask(body);
|
|
428
545
|
return json(res, 201, summary(task));
|
|
429
546
|
}
|
|
430
547
|
// Upload proxy: browsers can't PUT to Linear's storage (no CORS there) —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linear-grab-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Local bridge for Linear Grab — delegate issues from the browser panel to headless Claude Code sessions running in your repo, with live status and an upload relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|