linear-grab-bridge 0.8.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.
@@ -5,13 +5,20 @@
5
5
  *
6
6
  * npx linear-grab-bridge [--port 4577] [--dir .] [--claude claude]
7
7
  *
8
- * Each task spawns a headless `claude -p` session (stream-json output) in the
9
- * repo directory. The browser polls task status over localhost. Zero deps.
8
+ * v0.9: interactive sessions. Each task runs `claude -p` with stream-json
9
+ * INPUT + OUTPUT the session stays alive after each result, so the panel
10
+ * can send follow-up messages, switch models (applied via --resume respawn),
11
+ * read live token/context usage, and copy a `claude --resume <id>` command.
12
+ * Task history persists to ~/.linear-grab/ across restarts. Zero deps.
10
13
  * Binds 127.0.0.1 only — never exposed to the network.
11
14
  */
12
15
  import { createServer } from 'node:http';
13
- import { spawn } from 'node:child_process';
16
+ import { spawn, execFile } from 'node:child_process';
14
17
  import { randomUUID } from 'node:crypto';
18
+ import { createHash } from 'node:crypto';
19
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
15
22
 
16
23
  const argv = process.argv.slice(2);
17
24
  const flag = (name, fallback) => {
@@ -21,19 +28,82 @@ const flag = (name, fallback) => {
21
28
  const PORT = Number(flag('--port', '4577'));
22
29
  const DIR = flag('--dir', process.cwd());
23
30
  const CLAUDE_BIN = flag('--claude', 'claude');
24
- const VERSION = '0.8.0';
25
- const MAX_TAIL = 200;
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
+ }
41
+ const MAX_TAIL = 300;
42
+ const IDLE_KILL_MS = 30 * 60_000; // free an idle interactive session after 30min (resumable)
26
43
 
27
44
  /** @type {Map<string, any>} */
28
45
  const tasks = new Map();
29
46
 
47
+ // ---- persistence -----------------------------------------------------------
48
+
49
+ const HISTORY_DIR = join(homedir(), '.linear-grab');
50
+ const HISTORY_FILE = join(
51
+ HISTORY_DIR,
52
+ `bridge-${createHash('sha1').update(DIR).digest('hex').slice(0, 10)}.json`,
53
+ );
54
+
55
+ function loadHistory() {
56
+ try {
57
+ const items = JSON.parse(readFileSync(HISTORY_FILE, 'utf8'));
58
+ for (const t of items) {
59
+ tasks.set(t.id, { ...t, child: null, alive: false });
60
+ }
61
+ console.log(` history: ${items.length} past task(s) loaded`);
62
+ } catch {
63
+ /* first run */
64
+ }
65
+ }
66
+
67
+ let saveTimer = null;
68
+ function saveHistory() {
69
+ if (saveTimer) return;
70
+ saveTimer = setTimeout(() => {
71
+ saveTimer = null;
72
+ try {
73
+ mkdirSync(HISTORY_DIR, { recursive: true });
74
+ const items = [...tasks.values()]
75
+ .sort((a, b) => b.startedAt - a.startedAt)
76
+ .slice(0, 100)
77
+ .map((t) => ({
78
+ id: t.id,
79
+ title: t.title,
80
+ status: t.status === 'running' ? 'stopped' : t.status,
81
+ startedAt: t.startedAt,
82
+ endedAt: t.endedAt,
83
+ lastText: t.lastText,
84
+ result: t.result,
85
+ sessionId: t.sessionId ?? null,
86
+ model: t.model ?? null,
87
+ usage: t.usage ?? null,
88
+ startCommit: t.startCommit ?? null,
89
+ subagents: t.subagents ?? 0,
90
+ tail: (t.tail ?? []).slice(-60),
91
+ }));
92
+ writeFileSync(HISTORY_FILE, JSON.stringify(items));
93
+ } catch {
94
+ /* best-effort */
95
+ }
96
+ }, 600);
97
+ }
98
+
99
+ // ---- helpers ---------------------------------------------------------------
100
+
30
101
  const CORS = {
31
102
  'Access-Control-Allow-Origin': '*',
32
103
  'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
33
104
  'Access-Control-Allow-Headers': 'content-type,x-upload-url,x-upload-headers',
34
105
  };
35
106
 
36
- /** Upload proxy targets — Linear's storage only (SSRF guard). */
37
107
  const UPLOAD_HOSTS = /(^|\.)uploads\.linear\.app$|(^|\.)storage\.googleapis\.com$/;
38
108
 
39
109
  function json(res, code, obj) {
@@ -67,36 +137,60 @@ function summary(t) {
67
137
  startedAt: t.startedAt,
68
138
  endedAt: t.endedAt ?? null,
69
139
  lastText: t.lastText,
70
- result: t.status === 'done' ? t.result?.slice(0, 2000) ?? null : null,
140
+ result: t.status === 'done' ? (t.result?.slice(0, 2000) ?? null) : null,
141
+ sessionId: t.sessionId ?? null,
142
+ model: t.model ?? null,
143
+ pendingModel: t.pendingModel ?? null,
144
+ alive: !!t.alive,
145
+ usage: t.usage ?? null,
146
+ subagents: t.subagents ?? 0,
147
+ permissionMode: t.permissionMode ?? 'acceptEdits',
71
148
  };
72
149
  }
73
150
 
74
- function startTask({ title, prompt }) {
75
- const id = randomUUID().slice(0, 8);
76
- const task = {
77
- id,
78
- title: String(title ?? 'Task').slice(0, 200),
79
- status: 'running',
80
- startedAt: Date.now(),
81
- endedAt: null,
82
- lastText: 'Starting Claude Code…',
83
- tail: [],
84
- result: null,
85
- child: null,
86
- };
87
- tasks.set(id, task);
151
+ function pushTail(task, kind, text) {
152
+ task.tail.push({ at: Date.now(), kind, text });
153
+ if (task.tail.length > MAX_TAIL) task.tail.shift();
154
+ saveHistory();
155
+ }
88
156
 
89
- // Headless Claude Code: prompt over stdin (avoids argv limits), streamed
90
- // JSON events out. acceptEdits lets it actually work unattended; pass extra
91
- // flags via --claude-args if you need a different permission posture.
92
- const child = spawn(
93
- CLAUDE_BIN,
94
- ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', 'acceptEdits'],
95
- { cwd: DIR, stdio: ['pipe', 'pipe', 'pipe'] },
157
+ function userMessageLine(text) {
158
+ return (
159
+ JSON.stringify({
160
+ type: 'user',
161
+ message: { role: 'user', content: [{ type: 'text', text }] },
162
+ }) + '\n'
96
163
  );
164
+ }
165
+
166
+ // ---- claude process management ---------------------------------------------
167
+
168
+ function startProcess(task, { resume, initialText }) {
169
+ const args = [
170
+ '-p',
171
+ '--output-format',
172
+ 'stream-json',
173
+ '--input-format',
174
+ 'stream-json',
175
+ '--verbose',
176
+ '--permission-mode',
177
+ task.permissionMode || 'acceptEdits',
178
+ ];
179
+ if (task.model) args.push('--model', task.model);
180
+ if (resume) args.push('--resume', resume);
181
+
182
+ const child = spawn(CLAUDE_BIN, args, {
183
+ cwd: DIR,
184
+ stdio: ['pipe', 'pipe', 'pipe'],
185
+ env: { ...process.env, ...(task.env ?? {}) },
186
+ });
97
187
  task.child = child;
98
- child.stdin.write(String(prompt ?? ''));
99
- child.stdin.end();
188
+ task.alive = true;
189
+ task.pendingModel = null;
190
+ if (initialText) {
191
+ child.stdin.write(userMessageLine(initialText));
192
+ task.status = 'running';
193
+ }
100
194
 
101
195
  let buffer = '';
102
196
  child.stdout.on('data', (chunk) => {
@@ -107,58 +201,219 @@ function startTask({ title, prompt }) {
107
201
  buffer = buffer.slice(nl + 1);
108
202
  if (!line) continue;
109
203
  try {
110
- const event = JSON.parse(line);
111
- ingest(task, event);
204
+ ingest(task, JSON.parse(line));
112
205
  } catch {
113
- pushTail(task, line.slice(0, 500));
206
+ pushTail(task, 'raw', line.slice(0, 500));
114
207
  }
115
208
  }
116
209
  });
117
- child.stderr.on('data', (chunk) => pushTail(task, `[stderr] ${String(chunk).slice(0, 500)}`));
210
+ child.stderr.on('data', (chunk) => pushTail(task, 'stderr', String(chunk).slice(0, 500)));
118
211
  child.on('error', (err) => {
119
212
  task.status = 'error';
213
+ task.alive = false;
120
214
  task.endedAt = Date.now();
121
215
  task.lastText = `Failed to launch "${CLAUDE_BIN}" — is Claude Code installed and on PATH? (${err.message})`;
216
+ saveHistory();
122
217
  });
123
218
  child.on('exit', (code) => {
219
+ task.alive = false;
220
+ task.child = null;
124
221
  if (task.status === 'running') {
125
222
  task.status = code === 0 ? 'done' : 'error';
126
223
  if (task.status === 'error') task.lastText = `Exited with code ${code}. ${task.lastText}`;
224
+ task.endedAt = Date.now();
127
225
  }
128
- task.endedAt = Date.now();
129
- task.child = null;
226
+ saveHistory();
130
227
  });
131
- return task;
132
- }
133
228
 
134
- function pushTail(task, text) {
135
- task.tail.push({ at: Date.now(), text });
136
- if (task.tail.length > MAX_TAIL) task.tail.shift();
229
+ // Free idle sessions eventually — they stay resumable via --resume.
230
+ clearTimeout(task.idleTimer);
231
+ task.idleTimer = setInterval(() => {
232
+ if (task.alive && task.status !== 'running' && Date.now() - (task.lastActivity ?? 0) > IDLE_KILL_MS) {
233
+ try {
234
+ task.child?.stdin.end();
235
+ task.child?.kill('SIGTERM');
236
+ } catch {
237
+ /* already gone */
238
+ }
239
+ clearInterval(task.idleTimer);
240
+ }
241
+ }, 60_000);
137
242
  }
138
243
 
139
244
  function ingest(task, event) {
140
- // stream-json events: system/init, assistant messages, tool use, final result.
245
+ task.lastActivity = Date.now();
246
+ if (event.type === 'system' && event.subtype === 'init') {
247
+ task.sessionId = event.session_id ?? task.sessionId;
248
+ if (!task.model && event.model) task.model = event.model;
249
+ saveHistory();
250
+ return;
251
+ }
141
252
  if (event.type === 'assistant') {
142
- const parts = event.message?.content ?? [];
143
- for (const p of parts) {
253
+ const usage = event.message?.usage;
254
+ if (usage) {
255
+ const context =
256
+ (usage.input_tokens ?? 0) +
257
+ (usage.cache_read_input_tokens ?? 0) +
258
+ (usage.cache_creation_input_tokens ?? 0);
259
+ task.usage = {
260
+ ...(task.usage ?? { outputTokens: 0, costUsd: 0 }),
261
+ contextTokens: context,
262
+ outputTokens: (task.usage?.outputTokens ?? 0) + (usage.output_tokens ?? 0),
263
+ };
264
+ }
265
+ for (const p of event.message?.content ?? []) {
144
266
  if (p.type === 'text' && p.text?.trim()) {
145
267
  task.lastText = p.text.trim().slice(0, 300);
146
- pushTail(task, task.lastText);
268
+ pushTail(task, 'assistant', p.text.trim().slice(0, 2000));
147
269
  } else if (p.type === 'tool_use') {
148
- const label = `→ ${p.name}${p.input?.file_path ? ` ${p.input.file_path}` : ''}`;
149
- task.lastText = label.slice(0, 300);
150
- pushTail(task, label.slice(0, 500));
270
+ // Subagent fan-out is worth surfacing distinctly.
271
+ if (p.name === 'Task' || p.name === 'Agent') {
272
+ task.subagents = (task.subagents ?? 0) + 1;
273
+ const label = `⛓ subagent #${task.subagents}: ${String(p.input?.description ?? p.input?.prompt ?? '').slice(0, 140)}`;
274
+ task.lastText = label.slice(0, 300);
275
+ pushTail(task, 'subagent', label.slice(0, 500));
276
+ } else {
277
+ const label = `→ ${p.name}${p.input?.file_path ? ` ${p.input.file_path}` : p.input?.command ? ` ${String(p.input.command).slice(0, 120)}` : ''}`;
278
+ task.lastText = label.slice(0, 300);
279
+ pushTail(task, 'tool', label.slice(0, 500));
280
+ }
151
281
  }
152
282
  }
153
- } else if (event.type === 'result') {
283
+ return;
284
+ }
285
+ if (event.type === 'result') {
154
286
  task.result = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
155
287
  task.lastText = (task.result ?? '').slice(0, 300) || task.lastText;
156
288
  task.status = event.is_error ? 'error' : 'done';
157
289
  task.endedAt = Date.now();
158
- pushTail(task, `[result] ${(task.result ?? '').slice(0, 1000)}`);
290
+ if (event.usage || event.total_cost_usd != null) {
291
+ task.usage = {
292
+ ...(task.usage ?? {}),
293
+ costUsd: (task.usage?.costUsd ?? 0) + (event.total_cost_usd ?? 0),
294
+ };
295
+ }
296
+ pushTail(task, 'result', (task.result ?? '').slice(0, 2000));
297
+ // Session stays ALIVE for follow-up messages (multi-turn stream-json).
298
+ return;
299
+ }
300
+ }
301
+
302
+ function startTask({ title, prompt, model, env, permissionMode }) {
303
+ const id = randomUUID().slice(0, 8);
304
+ const task = {
305
+ id,
306
+ permissionMode: ['acceptEdits', 'bypassPermissions', 'default', 'plan'].includes(
307
+ String(permissionMode),
308
+ )
309
+ ? String(permissionMode)
310
+ : 'acceptEdits',
311
+ title: String(title ?? 'Task').slice(0, 200),
312
+ status: 'running',
313
+ startedAt: Date.now(),
314
+ endedAt: null,
315
+ lastText: 'Starting Claude Code…',
316
+ tail: [],
317
+ result: null,
318
+ child: null,
319
+ alive: false,
320
+ sessionId: null,
321
+ model: model ? String(model) : null,
322
+ env: env && typeof env === 'object' ? env : null,
323
+ usage: null,
324
+ lastActivity: Date.now(),
325
+ };
326
+ tasks.set(id, task);
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
+ });
332
+ startProcess(task, { initialText: String(prompt ?? '') });
333
+ return task;
334
+ }
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
+
388
+ /** Send a follow-up. Respawns via --resume when the process is gone or a model
389
+ change is pending — that's also how model switches take effect. */
390
+ function sendMessage(task, text) {
391
+ pushTail(task, 'user', text.slice(0, 2000));
392
+ const needsRespawn = !task.alive || !!task.pendingModel;
393
+ if (needsRespawn) {
394
+ if (task.pendingModel) task.model = task.pendingModel;
395
+ if (task.alive && task.child) {
396
+ try {
397
+ task.child.stdin.end();
398
+ task.child.kill('SIGTERM');
399
+ } catch {
400
+ /* ignore */
401
+ }
402
+ }
403
+ if (!task.sessionId) throw new Error('No session to resume');
404
+ task.status = 'running';
405
+ task.endedAt = null;
406
+ startProcess(task, { resume: task.sessionId, initialText: text });
407
+ } else {
408
+ task.child.stdin.write(userMessageLine(text));
409
+ task.status = 'running';
410
+ task.endedAt = null;
159
411
  }
412
+ saveHistory();
160
413
  }
161
414
 
415
+ // ---- HTTP ------------------------------------------------------------------
416
+
162
417
  createServer(async (req, res) => {
163
418
  try {
164
419
  if (req.method === 'OPTIONS') {
@@ -183,29 +438,70 @@ createServer(async (req, res) => {
183
438
  if (req.method === 'GET' && detail) {
184
439
  const t = tasks.get(detail[1]);
185
440
  return t
186
- ? json(res, 200, { ...summary(t), tail: t.tail.slice(-60) })
441
+ ? json(res, 200, { ...summary(t), tail: (t.tail ?? []).slice(-120) })
187
442
  : json(res, 404, { error: 'not found' });
188
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
+ }
189
450
  const stop = url.pathname.match(/^\/tasks\/([\w-]+)\/stop$/);
190
451
  if (req.method === 'POST' && stop) {
191
452
  const t = tasks.get(stop[1]);
192
- if (t?.child && t.status === 'running') {
193
- t.child.kill('SIGTERM');
194
- t.status = 'stopped';
195
- t.endedAt = Date.now();
196
- t.lastText = 'Stopped from the panel.';
453
+ if (t?.child && t.alive) {
454
+ try {
455
+ t.child.stdin.end();
456
+ t.child.kill('SIGTERM');
457
+ } catch {
458
+ /* ignore */
459
+ }
460
+ if (t.status === 'running') {
461
+ t.status = 'stopped';
462
+ t.endedAt = Date.now();
463
+ t.lastText = 'Stopped from the panel.';
464
+ }
465
+ saveHistory();
197
466
  }
198
467
  return json(res, 200, { ok: true });
199
468
  }
469
+ const message = url.pathname.match(/^\/tasks\/([\w-]+)\/message$/);
470
+ if (req.method === 'POST' && message) {
471
+ const t = tasks.get(message[1]);
472
+ if (!t) return json(res, 404, { error: 'not found' });
473
+ const body = await readBody(req);
474
+ if (!body.text) return json(res, 400, { error: 'text required' });
475
+ try {
476
+ sendMessage(t, String(body.text));
477
+ return json(res, 200, summary(t));
478
+ } catch (err) {
479
+ return json(res, 409, { error: err instanceof Error ? err.message : String(err) });
480
+ }
481
+ }
482
+ const model = url.pathname.match(/^\/tasks\/([\w-]+)\/model$/);
483
+ if (req.method === 'POST' && model) {
484
+ const t = tasks.get(model[1]);
485
+ if (!t) return json(res, 404, { error: 'not found' });
486
+ const body = await readBody(req);
487
+ // Takes effect on the NEXT message (resume respawn) — killing an
488
+ // in-flight turn to switch models would lose work.
489
+ t.pendingModel = body.model ? String(body.model) : null;
490
+ if (!t.alive && t.pendingModel) {
491
+ t.model = t.pendingModel;
492
+ t.pendingModel = null;
493
+ }
494
+ saveHistory();
495
+ return json(res, 200, summary(t));
496
+ }
200
497
  if (req.method === 'POST' && url.pathname === '/tasks') {
201
498
  const body = await readBody(req);
202
499
  if (!body.prompt) return json(res, 400, { error: 'prompt required' });
203
500
  const task = startTask(body);
204
501
  return json(res, 201, summary(task));
205
502
  }
206
- // Upload proxy: browsers can't PUT to Linear's storage (its endpoint has
207
- // no CORS support) — this local process can. Panel sends the signed URL +
208
- // headers it got from the fileUpload mutation; we relay the bytes.
503
+ // Upload proxy: browsers can't PUT to Linear's storage (no CORS there) —
504
+ // this local process can. SSRF-guarded to Linear storage hosts.
209
505
  if (req.method === 'POST' && url.pathname === '/put') {
210
506
  const target = String(req.headers['x-upload-url'] ?? '');
211
507
  let host = '';
@@ -241,5 +537,6 @@ createServer(async (req, res) => {
241
537
  console.log(`linear-grab bridge v${VERSION}`);
242
538
  console.log(` repo: ${DIR}`);
243
539
  console.log(` listen: http://127.0.0.1:${PORT} (localhost only)`);
244
- console.log(` tasks run: ${CLAUDE_BIN} -p --permission-mode acceptEdits`);
540
+ console.log(` tasks run: ${CLAUDE_BIN} -p (interactive stream-json, acceptEdits)`);
541
+ loadHistory();
245
542
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linear-grab-bridge",
3
- "version": "0.8.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": {