linear-grab-bridge 0.10.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 +56 -12
- package/package.json +1 -1
package/linear-grab-bridge.mjs
CHANGED
|
@@ -31,13 +31,34 @@ const CLAUDE_BIN = flag('--claude', 'claude');
|
|
|
31
31
|
const VERSION = '0.10.0';
|
|
32
32
|
|
|
33
33
|
/** Best-effort command runner (git/gh introspection). Never throws. */
|
|
34
|
-
function run(cmd, args) {
|
|
34
|
+
function run(cmd, args, cwd = DIR) {
|
|
35
35
|
return new Promise((resolve) => {
|
|
36
|
-
execFile(cmd, args, { cwd
|
|
36
|
+
execFile(cmd, args, { cwd, timeout: 15_000, maxBuffer: 2_000_000 }, (err, stdout) =>
|
|
37
37
|
resolve(err ? null : stdout.toString()),
|
|
38
38
|
);
|
|
39
39
|
});
|
|
40
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
|
+
}
|
|
41
62
|
const MAX_TAIL = 300;
|
|
42
63
|
const IDLE_KILL_MS = 30 * 60_000; // free an idle interactive session after 30min (resumable)
|
|
43
64
|
|
|
@@ -87,6 +108,8 @@ function saveHistory() {
|
|
|
87
108
|
usage: t.usage ?? null,
|
|
88
109
|
startCommit: t.startCommit ?? null,
|
|
89
110
|
subagents: t.subagents ?? 0,
|
|
111
|
+
worktree: t.worktree ?? null,
|
|
112
|
+
cwd: t.cwd ?? null,
|
|
90
113
|
tail: (t.tail ?? []).slice(-60),
|
|
91
114
|
}));
|
|
92
115
|
writeFileSync(HISTORY_FILE, JSON.stringify(items));
|
|
@@ -145,6 +168,7 @@ function summary(t) {
|
|
|
145
168
|
usage: t.usage ?? null,
|
|
146
169
|
subagents: t.subagents ?? 0,
|
|
147
170
|
permissionMode: t.permissionMode ?? 'acceptEdits',
|
|
171
|
+
worktree: t.worktree ?? null,
|
|
148
172
|
};
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -180,7 +204,7 @@ function startProcess(task, { resume, initialText }) {
|
|
|
180
204
|
if (resume) args.push('--resume', resume);
|
|
181
205
|
|
|
182
206
|
const child = spawn(CLAUDE_BIN, args, {
|
|
183
|
-
cwd: DIR,
|
|
207
|
+
cwd: task.cwd ?? DIR,
|
|
184
208
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
185
209
|
env: { ...process.env, ...(task.env ?? {}) },
|
|
186
210
|
});
|
|
@@ -299,7 +323,7 @@ function ingest(task, event) {
|
|
|
299
323
|
}
|
|
300
324
|
}
|
|
301
325
|
|
|
302
|
-
function startTask({ title, prompt, model, env, permissionMode }) {
|
|
326
|
+
async function startTask({ title, prompt, model, env, permissionMode, worktree }) {
|
|
303
327
|
const id = randomUUID().slice(0, 8);
|
|
304
328
|
const task = {
|
|
305
329
|
id,
|
|
@@ -325,19 +349,28 @@ function startTask({ title, prompt, model, env, permissionMode }) {
|
|
|
325
349
|
};
|
|
326
350
|
tasks.set(id, task);
|
|
327
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
|
+
}
|
|
328
362
|
// Snapshot HEAD so the Changes view can attribute work to this task.
|
|
329
|
-
|
|
330
|
-
task.startCommit = out?.trim() || null;
|
|
331
|
-
});
|
|
363
|
+
task.startCommit = ((await run('git', ['rev-parse', 'HEAD'], task.cwd ?? DIR)) ?? '').trim() || null;
|
|
332
364
|
startProcess(task, { initialText: String(prompt ?? '') });
|
|
333
365
|
return task;
|
|
334
366
|
}
|
|
335
367
|
|
|
336
368
|
/** What this task changed: files (+/−), branch, untracked, matching PRs. */
|
|
337
369
|
async function computeDiff(task) {
|
|
338
|
-
const
|
|
370
|
+
const cwd = task.cwd ?? DIR;
|
|
371
|
+
const branch = (await run('git', ['branch', '--show-current'], cwd))?.trim() ?? '';
|
|
339
372
|
const base = task.startCommit;
|
|
340
|
-
const numstat = (await run('git', ['diff', '--numstat', ...(base ? [base] : [])])) ?? '';
|
|
373
|
+
const numstat = (await run('git', ['diff', '--numstat', ...(base ? [base] : [])], cwd)) ?? '';
|
|
341
374
|
const files = numstat
|
|
342
375
|
.split('\n')
|
|
343
376
|
.filter(Boolean)
|
|
@@ -351,7 +384,7 @@ async function computeDiff(task) {
|
|
|
351
384
|
};
|
|
352
385
|
})
|
|
353
386
|
.filter((f) => f.path);
|
|
354
|
-
const untracked = ((await run('git', ['status', '--porcelain'])) ?? '')
|
|
387
|
+
const untracked = ((await run('git', ['status', '--porcelain'], cwd)) ?? '')
|
|
355
388
|
.split('\n')
|
|
356
389
|
.filter((l) => l.startsWith('??'))
|
|
357
390
|
.map((l) => l.slice(3).trim())
|
|
@@ -367,7 +400,7 @@ async function computeDiff(task) {
|
|
|
367
400
|
]) {
|
|
368
401
|
if (!args) continue;
|
|
369
402
|
try {
|
|
370
|
-
const out = await run('gh', args);
|
|
403
|
+
const out = await run('gh', args, cwd);
|
|
371
404
|
for (const pr of JSON.parse(out ?? '[]')) prs.set(pr.url, pr);
|
|
372
405
|
} catch {
|
|
373
406
|
/* gh missing or not a repo with remote */
|
|
@@ -466,6 +499,17 @@ createServer(async (req, res) => {
|
|
|
466
499
|
}
|
|
467
500
|
return json(res, 200, { ok: true });
|
|
468
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
|
+
}
|
|
469
513
|
const message = url.pathname.match(/^\/tasks\/([\w-]+)\/message$/);
|
|
470
514
|
if (req.method === 'POST' && message) {
|
|
471
515
|
const t = tasks.get(message[1]);
|
|
@@ -497,7 +541,7 @@ createServer(async (req, res) => {
|
|
|
497
541
|
if (req.method === 'POST' && url.pathname === '/tasks') {
|
|
498
542
|
const body = await readBody(req);
|
|
499
543
|
if (!body.prompt) return json(res, 400, { error: 'prompt required' });
|
|
500
|
-
const task = startTask(body);
|
|
544
|
+
const task = await startTask(body);
|
|
501
545
|
return json(res, 201, summary(task));
|
|
502
546
|
}
|
|
503
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": {
|