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.
@@ -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: DIR, timeout: 8_000, maxBuffer: 2_000_000 }, (err, stdout) =>
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
- void run('git', ['rev-parse', 'HEAD']).then((out) => {
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 branch = (await run('git', ['branch', '--show-current']))?.trim() ?? '';
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.10.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": {