linear-grab-bridge 0.9.0 → 0.10.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,16 @@ 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) {
35
+ return new Promise((resolve) => {
36
+ execFile(cmd, args, { cwd: DIR, timeout: 8_000, maxBuffer: 2_000_000 }, (err, stdout) =>
37
+ resolve(err ? null : stdout.toString()),
38
+ );
39
+ });
40
+ }
32
41
  const MAX_TAIL = 300;
33
42
  const IDLE_KILL_MS = 30 * 60_000; // free an idle interactive session after 30min (resumable)
34
43
 
@@ -76,6 +85,8 @@ function saveHistory() {
76
85
  sessionId: t.sessionId ?? null,
77
86
  model: t.model ?? null,
78
87
  usage: t.usage ?? null,
88
+ startCommit: t.startCommit ?? null,
89
+ subagents: t.subagents ?? 0,
79
90
  tail: (t.tail ?? []).slice(-60),
80
91
  }));
81
92
  writeFileSync(HISTORY_FILE, JSON.stringify(items));
@@ -314,10 +325,66 @@ function startTask({ title, prompt, model, env, permissionMode }) {
314
325
  };
315
326
  tasks.set(id, task);
316
327
  pushTail(task, 'user', String(prompt ?? '').slice(0, 2000));
328
+ // 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
+ });
317
332
  startProcess(task, { initialText: String(prompt ?? '') });
318
333
  return task;
319
334
  }
320
335
 
336
+ /** What this task changed: files (+/−), branch, untracked, matching PRs. */
337
+ async function computeDiff(task) {
338
+ const branch = (await run('git', ['branch', '--show-current']))?.trim() ?? '';
339
+ const base = task.startCommit;
340
+ const numstat = (await run('git', ['diff', '--numstat', ...(base ? [base] : [])])) ?? '';
341
+ const files = numstat
342
+ .split('\n')
343
+ .filter(Boolean)
344
+ .map((line) => {
345
+ const [a, d, ...path] = line.split('\t');
346
+ return {
347
+ path: path.join('\t'),
348
+ added: a === '-' ? 0 : Number(a),
349
+ deleted: d === '-' ? 0 : Number(d),
350
+ binary: a === '-',
351
+ };
352
+ })
353
+ .filter((f) => f.path);
354
+ const untracked = ((await run('git', ['status', '--porcelain'])) ?? '')
355
+ .split('\n')
356
+ .filter((l) => l.startsWith('??'))
357
+ .map((l) => l.slice(3).trim())
358
+ .filter(Boolean)
359
+ .slice(0, 40);
360
+
361
+ // PRs: by current head branch AND by the issue identifier in the title.
362
+ const prs = new Map();
363
+ const ident = task.title.match(/^([A-Z][A-Z0-9]*-\d+)/)?.[1];
364
+ for (const args of [
365
+ branch ? ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'url,title,state', '--limit', '3'] : null,
366
+ ident ? ['pr', 'list', '--search', ident, '--state', 'all', '--json', 'url,title,state', '--limit', '3'] : null,
367
+ ]) {
368
+ if (!args) continue;
369
+ try {
370
+ const out = await run('gh', args);
371
+ for (const pr of JSON.parse(out ?? '[]')) prs.set(pr.url, pr);
372
+ } catch {
373
+ /* gh missing or not a repo with remote */
374
+ }
375
+ }
376
+
377
+ return {
378
+ branch,
379
+ baseCommit: base ?? null,
380
+ files: files.slice(0, 60),
381
+ untracked,
382
+ totalAdded: files.reduce((n, f) => n + f.added, 0),
383
+ totalDeleted: files.reduce((n, f) => n + f.deleted, 0),
384
+ prs: [...prs.values()],
385
+ };
386
+ }
387
+
321
388
  /** Send a follow-up. Respawns via --resume when the process is gone or a model
322
389
  change is pending — that's also how model switches take effect. */
323
390
  function sendMessage(task, text) {
@@ -374,6 +441,12 @@ createServer(async (req, res) => {
374
441
  ? json(res, 200, { ...summary(t), tail: (t.tail ?? []).slice(-120) })
375
442
  : json(res, 404, { error: 'not found' });
376
443
  }
444
+ const diff = url.pathname.match(/^\/tasks\/([\w-]+)\/diff$/);
445
+ if (req.method === 'GET' && diff) {
446
+ const t = tasks.get(diff[1]);
447
+ if (!t) return json(res, 404, { error: 'not found' });
448
+ return json(res, 200, await computeDiff(t));
449
+ }
377
450
  const stop = url.pathname.match(/^\/tasks\/([\w-]+)\/stop$/);
378
451
  if (req.method === 'POST' && stop) {
379
452
  const t = tasks.get(stop[1]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linear-grab-bridge",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": {