linear-grab-bridge 0.10.0 → 0.12.1

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.
@@ -28,16 +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.10.0';
31
+ const VERSION = '0.12.1';
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
  });
@@ -250,6 +274,8 @@ function ingest(task, event) {
250
274
  return;
251
275
  }
252
276
  if (event.type === 'assistant') {
277
+ // The stream tells us the ACTUAL model — surface it even for defaults.
278
+ if (event.message?.model) task.model = event.message.model;
253
279
  const usage = event.message?.usage;
254
280
  if (usage) {
255
281
  const context =
@@ -299,7 +325,7 @@ function ingest(task, event) {
299
325
  }
300
326
  }
301
327
 
302
- function startTask({ title, prompt, model, env, permissionMode }) {
328
+ async function startTask({ title, prompt, model, env, permissionMode, worktree }) {
303
329
  const id = randomUUID().slice(0, 8);
304
330
  const task = {
305
331
  id,
@@ -325,19 +351,28 @@ function startTask({ title, prompt, model, env, permissionMode }) {
325
351
  };
326
352
  tasks.set(id, task);
327
353
  pushTail(task, 'user', String(prompt ?? '').slice(0, 2000));
354
+ if (worktree) {
355
+ const wt = await setupWorktree(task);
356
+ if (wt) {
357
+ task.worktree = wt;
358
+ task.cwd = wt.path;
359
+ pushTail(task, 'tool', `⎇ isolated worktree: ${wt.branch} @ ${wt.path}`);
360
+ } else {
361
+ pushTail(task, 'stderr', 'Worktree setup failed — running in the main working tree.');
362
+ }
363
+ }
328
364
  // 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
- });
365
+ task.startCommit = ((await run('git', ['rev-parse', 'HEAD'], task.cwd ?? DIR)) ?? '').trim() || null;
332
366
  startProcess(task, { initialText: String(prompt ?? '') });
333
367
  return task;
334
368
  }
335
369
 
336
370
  /** What this task changed: files (+/−), branch, untracked, matching PRs. */
337
371
  async function computeDiff(task) {
338
- const branch = (await run('git', ['branch', '--show-current']))?.trim() ?? '';
372
+ const cwd = task.cwd ?? DIR;
373
+ const branch = (await run('git', ['branch', '--show-current'], cwd))?.trim() ?? '';
339
374
  const base = task.startCommit;
340
- const numstat = (await run('git', ['diff', '--numstat', ...(base ? [base] : [])])) ?? '';
375
+ const numstat = (await run('git', ['diff', '--numstat', ...(base ? [base] : [])], cwd)) ?? '';
341
376
  const files = numstat
342
377
  .split('\n')
343
378
  .filter(Boolean)
@@ -351,7 +386,7 @@ async function computeDiff(task) {
351
386
  };
352
387
  })
353
388
  .filter((f) => f.path);
354
- const untracked = ((await run('git', ['status', '--porcelain'])) ?? '')
389
+ const untracked = ((await run('git', ['status', '--porcelain'], cwd)) ?? '')
355
390
  .split('\n')
356
391
  .filter((l) => l.startsWith('??'))
357
392
  .map((l) => l.slice(3).trim())
@@ -367,7 +402,7 @@ async function computeDiff(task) {
367
402
  ]) {
368
403
  if (!args) continue;
369
404
  try {
370
- const out = await run('gh', args);
405
+ const out = await run('gh', args, cwd);
371
406
  for (const pr of JSON.parse(out ?? '[]')) prs.set(pr.url, pr);
372
407
  } catch {
373
408
  /* gh missing or not a repo with remote */
@@ -466,6 +501,17 @@ createServer(async (req, res) => {
466
501
  }
467
502
  return json(res, 200, { ok: true });
468
503
  }
504
+ const wtRemove = url.pathname.match(/^\/tasks\/([\w-]+)\/worktree\/remove$/);
505
+ if (req.method === 'POST' && wtRemove) {
506
+ const t = tasks.get(wtRemove[1]);
507
+ if (!t?.worktree || t.worktree.removed) return json(res, 404, { error: 'no worktree' });
508
+ if (t.status === 'running') return json(res, 409, { error: 'task still running' });
509
+ await run('git', ['worktree', 'remove', '--force', t.worktree.path]);
510
+ t.worktree.removed = true;
511
+ t.cwd = null;
512
+ saveHistory();
513
+ return json(res, 200, summary(t));
514
+ }
469
515
  const message = url.pathname.match(/^\/tasks\/([\w-]+)\/message$/);
470
516
  if (req.method === 'POST' && message) {
471
517
  const t = tasks.get(message[1]);
@@ -497,7 +543,7 @@ createServer(async (req, res) => {
497
543
  if (req.method === 'POST' && url.pathname === '/tasks') {
498
544
  const body = await readBody(req);
499
545
  if (!body.prompt) return json(res, 400, { error: 'prompt required' });
500
- const task = startTask(body);
546
+ const task = await startTask(body);
501
547
  return json(res, 201, summary(task));
502
548
  }
503
549
  // 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.12.1",
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": {