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.
@@ -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.9.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.9.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": {