linear-grab-bridge 0.8.0 → 0.9.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
16
  import { spawn } 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,71 @@ 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.9.0';
32
+ const MAX_TAIL = 300;
33
+ const IDLE_KILL_MS = 30 * 60_000; // free an idle interactive session after 30min (resumable)
26
34
 
27
35
  /** @type {Map<string, any>} */
28
36
  const tasks = new Map();
29
37
 
38
+ // ---- persistence -----------------------------------------------------------
39
+
40
+ const HISTORY_DIR = join(homedir(), '.linear-grab');
41
+ const HISTORY_FILE = join(
42
+ HISTORY_DIR,
43
+ `bridge-${createHash('sha1').update(DIR).digest('hex').slice(0, 10)}.json`,
44
+ );
45
+
46
+ function loadHistory() {
47
+ try {
48
+ const items = JSON.parse(readFileSync(HISTORY_FILE, 'utf8'));
49
+ for (const t of items) {
50
+ tasks.set(t.id, { ...t, child: null, alive: false });
51
+ }
52
+ console.log(` history: ${items.length} past task(s) loaded`);
53
+ } catch {
54
+ /* first run */
55
+ }
56
+ }
57
+
58
+ let saveTimer = null;
59
+ function saveHistory() {
60
+ if (saveTimer) return;
61
+ saveTimer = setTimeout(() => {
62
+ saveTimer = null;
63
+ try {
64
+ mkdirSync(HISTORY_DIR, { recursive: true });
65
+ const items = [...tasks.values()]
66
+ .sort((a, b) => b.startedAt - a.startedAt)
67
+ .slice(0, 100)
68
+ .map((t) => ({
69
+ id: t.id,
70
+ title: t.title,
71
+ status: t.status === 'running' ? 'stopped' : t.status,
72
+ startedAt: t.startedAt,
73
+ endedAt: t.endedAt,
74
+ lastText: t.lastText,
75
+ result: t.result,
76
+ sessionId: t.sessionId ?? null,
77
+ model: t.model ?? null,
78
+ usage: t.usage ?? null,
79
+ tail: (t.tail ?? []).slice(-60),
80
+ }));
81
+ writeFileSync(HISTORY_FILE, JSON.stringify(items));
82
+ } catch {
83
+ /* best-effort */
84
+ }
85
+ }, 600);
86
+ }
87
+
88
+ // ---- helpers ---------------------------------------------------------------
89
+
30
90
  const CORS = {
31
91
  'Access-Control-Allow-Origin': '*',
32
92
  'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
33
93
  'Access-Control-Allow-Headers': 'content-type,x-upload-url,x-upload-headers',
34
94
  };
35
95
 
36
- /** Upload proxy targets — Linear's storage only (SSRF guard). */
37
96
  const UPLOAD_HOSTS = /(^|\.)uploads\.linear\.app$|(^|\.)storage\.googleapis\.com$/;
38
97
 
39
98
  function json(res, code, obj) {
@@ -67,36 +126,60 @@ function summary(t) {
67
126
  startedAt: t.startedAt,
68
127
  endedAt: t.endedAt ?? null,
69
128
  lastText: t.lastText,
70
- result: t.status === 'done' ? t.result?.slice(0, 2000) ?? null : null,
129
+ result: t.status === 'done' ? (t.result?.slice(0, 2000) ?? null) : null,
130
+ sessionId: t.sessionId ?? null,
131
+ model: t.model ?? null,
132
+ pendingModel: t.pendingModel ?? null,
133
+ alive: !!t.alive,
134
+ usage: t.usage ?? null,
135
+ subagents: t.subagents ?? 0,
136
+ permissionMode: t.permissionMode ?? 'acceptEdits',
71
137
  };
72
138
  }
73
139
 
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);
140
+ function pushTail(task, kind, text) {
141
+ task.tail.push({ at: Date.now(), kind, text });
142
+ if (task.tail.length > MAX_TAIL) task.tail.shift();
143
+ saveHistory();
144
+ }
88
145
 
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'] },
146
+ function userMessageLine(text) {
147
+ return (
148
+ JSON.stringify({
149
+ type: 'user',
150
+ message: { role: 'user', content: [{ type: 'text', text }] },
151
+ }) + '\n'
96
152
  );
153
+ }
154
+
155
+ // ---- claude process management ---------------------------------------------
156
+
157
+ function startProcess(task, { resume, initialText }) {
158
+ const args = [
159
+ '-p',
160
+ '--output-format',
161
+ 'stream-json',
162
+ '--input-format',
163
+ 'stream-json',
164
+ '--verbose',
165
+ '--permission-mode',
166
+ task.permissionMode || 'acceptEdits',
167
+ ];
168
+ if (task.model) args.push('--model', task.model);
169
+ if (resume) args.push('--resume', resume);
170
+
171
+ const child = spawn(CLAUDE_BIN, args, {
172
+ cwd: DIR,
173
+ stdio: ['pipe', 'pipe', 'pipe'],
174
+ env: { ...process.env, ...(task.env ?? {}) },
175
+ });
97
176
  task.child = child;
98
- child.stdin.write(String(prompt ?? ''));
99
- child.stdin.end();
177
+ task.alive = true;
178
+ task.pendingModel = null;
179
+ if (initialText) {
180
+ child.stdin.write(userMessageLine(initialText));
181
+ task.status = 'running';
182
+ }
100
183
 
101
184
  let buffer = '';
102
185
  child.stdout.on('data', (chunk) => {
@@ -107,58 +190,163 @@ function startTask({ title, prompt }) {
107
190
  buffer = buffer.slice(nl + 1);
108
191
  if (!line) continue;
109
192
  try {
110
- const event = JSON.parse(line);
111
- ingest(task, event);
193
+ ingest(task, JSON.parse(line));
112
194
  } catch {
113
- pushTail(task, line.slice(0, 500));
195
+ pushTail(task, 'raw', line.slice(0, 500));
114
196
  }
115
197
  }
116
198
  });
117
- child.stderr.on('data', (chunk) => pushTail(task, `[stderr] ${String(chunk).slice(0, 500)}`));
199
+ child.stderr.on('data', (chunk) => pushTail(task, 'stderr', String(chunk).slice(0, 500)));
118
200
  child.on('error', (err) => {
119
201
  task.status = 'error';
202
+ task.alive = false;
120
203
  task.endedAt = Date.now();
121
204
  task.lastText = `Failed to launch "${CLAUDE_BIN}" — is Claude Code installed and on PATH? (${err.message})`;
205
+ saveHistory();
122
206
  });
123
207
  child.on('exit', (code) => {
208
+ task.alive = false;
209
+ task.child = null;
124
210
  if (task.status === 'running') {
125
211
  task.status = code === 0 ? 'done' : 'error';
126
212
  if (task.status === 'error') task.lastText = `Exited with code ${code}. ${task.lastText}`;
213
+ task.endedAt = Date.now();
127
214
  }
128
- task.endedAt = Date.now();
129
- task.child = null;
215
+ saveHistory();
130
216
  });
131
- return task;
132
- }
133
217
 
134
- function pushTail(task, text) {
135
- task.tail.push({ at: Date.now(), text });
136
- if (task.tail.length > MAX_TAIL) task.tail.shift();
218
+ // Free idle sessions eventually — they stay resumable via --resume.
219
+ clearTimeout(task.idleTimer);
220
+ task.idleTimer = setInterval(() => {
221
+ if (task.alive && task.status !== 'running' && Date.now() - (task.lastActivity ?? 0) > IDLE_KILL_MS) {
222
+ try {
223
+ task.child?.stdin.end();
224
+ task.child?.kill('SIGTERM');
225
+ } catch {
226
+ /* already gone */
227
+ }
228
+ clearInterval(task.idleTimer);
229
+ }
230
+ }, 60_000);
137
231
  }
138
232
 
139
233
  function ingest(task, event) {
140
- // stream-json events: system/init, assistant messages, tool use, final result.
234
+ task.lastActivity = Date.now();
235
+ if (event.type === 'system' && event.subtype === 'init') {
236
+ task.sessionId = event.session_id ?? task.sessionId;
237
+ if (!task.model && event.model) task.model = event.model;
238
+ saveHistory();
239
+ return;
240
+ }
141
241
  if (event.type === 'assistant') {
142
- const parts = event.message?.content ?? [];
143
- for (const p of parts) {
242
+ const usage = event.message?.usage;
243
+ if (usage) {
244
+ const context =
245
+ (usage.input_tokens ?? 0) +
246
+ (usage.cache_read_input_tokens ?? 0) +
247
+ (usage.cache_creation_input_tokens ?? 0);
248
+ task.usage = {
249
+ ...(task.usage ?? { outputTokens: 0, costUsd: 0 }),
250
+ contextTokens: context,
251
+ outputTokens: (task.usage?.outputTokens ?? 0) + (usage.output_tokens ?? 0),
252
+ };
253
+ }
254
+ for (const p of event.message?.content ?? []) {
144
255
  if (p.type === 'text' && p.text?.trim()) {
145
256
  task.lastText = p.text.trim().slice(0, 300);
146
- pushTail(task, task.lastText);
257
+ pushTail(task, 'assistant', p.text.trim().slice(0, 2000));
147
258
  } 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));
259
+ // Subagent fan-out is worth surfacing distinctly.
260
+ if (p.name === 'Task' || p.name === 'Agent') {
261
+ task.subagents = (task.subagents ?? 0) + 1;
262
+ const label = `⛓ subagent #${task.subagents}: ${String(p.input?.description ?? p.input?.prompt ?? '').slice(0, 140)}`;
263
+ task.lastText = label.slice(0, 300);
264
+ pushTail(task, 'subagent', label.slice(0, 500));
265
+ } else {
266
+ const label = `→ ${p.name}${p.input?.file_path ? ` ${p.input.file_path}` : p.input?.command ? ` ${String(p.input.command).slice(0, 120)}` : ''}`;
267
+ task.lastText = label.slice(0, 300);
268
+ pushTail(task, 'tool', label.slice(0, 500));
269
+ }
151
270
  }
152
271
  }
153
- } else if (event.type === 'result') {
272
+ return;
273
+ }
274
+ if (event.type === 'result') {
154
275
  task.result = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
155
276
  task.lastText = (task.result ?? '').slice(0, 300) || task.lastText;
156
277
  task.status = event.is_error ? 'error' : 'done';
157
278
  task.endedAt = Date.now();
158
- pushTail(task, `[result] ${(task.result ?? '').slice(0, 1000)}`);
279
+ if (event.usage || event.total_cost_usd != null) {
280
+ task.usage = {
281
+ ...(task.usage ?? {}),
282
+ costUsd: (task.usage?.costUsd ?? 0) + (event.total_cost_usd ?? 0),
283
+ };
284
+ }
285
+ pushTail(task, 'result', (task.result ?? '').slice(0, 2000));
286
+ // Session stays ALIVE for follow-up messages (multi-turn stream-json).
287
+ return;
288
+ }
289
+ }
290
+
291
+ function startTask({ title, prompt, model, env, permissionMode }) {
292
+ const id = randomUUID().slice(0, 8);
293
+ const task = {
294
+ id,
295
+ permissionMode: ['acceptEdits', 'bypassPermissions', 'default', 'plan'].includes(
296
+ String(permissionMode),
297
+ )
298
+ ? String(permissionMode)
299
+ : 'acceptEdits',
300
+ title: String(title ?? 'Task').slice(0, 200),
301
+ status: 'running',
302
+ startedAt: Date.now(),
303
+ endedAt: null,
304
+ lastText: 'Starting Claude Code…',
305
+ tail: [],
306
+ result: null,
307
+ child: null,
308
+ alive: false,
309
+ sessionId: null,
310
+ model: model ? String(model) : null,
311
+ env: env && typeof env === 'object' ? env : null,
312
+ usage: null,
313
+ lastActivity: Date.now(),
314
+ };
315
+ tasks.set(id, task);
316
+ pushTail(task, 'user', String(prompt ?? '').slice(0, 2000));
317
+ startProcess(task, { initialText: String(prompt ?? '') });
318
+ return task;
319
+ }
320
+
321
+ /** Send a follow-up. Respawns via --resume when the process is gone or a model
322
+ change is pending — that's also how model switches take effect. */
323
+ function sendMessage(task, text) {
324
+ pushTail(task, 'user', text.slice(0, 2000));
325
+ const needsRespawn = !task.alive || !!task.pendingModel;
326
+ if (needsRespawn) {
327
+ if (task.pendingModel) task.model = task.pendingModel;
328
+ if (task.alive && task.child) {
329
+ try {
330
+ task.child.stdin.end();
331
+ task.child.kill('SIGTERM');
332
+ } catch {
333
+ /* ignore */
334
+ }
335
+ }
336
+ if (!task.sessionId) throw new Error('No session to resume');
337
+ task.status = 'running';
338
+ task.endedAt = null;
339
+ startProcess(task, { resume: task.sessionId, initialText: text });
340
+ } else {
341
+ task.child.stdin.write(userMessageLine(text));
342
+ task.status = 'running';
343
+ task.endedAt = null;
159
344
  }
345
+ saveHistory();
160
346
  }
161
347
 
348
+ // ---- HTTP ------------------------------------------------------------------
349
+
162
350
  createServer(async (req, res) => {
163
351
  try {
164
352
  if (req.method === 'OPTIONS') {
@@ -183,29 +371,64 @@ createServer(async (req, res) => {
183
371
  if (req.method === 'GET' && detail) {
184
372
  const t = tasks.get(detail[1]);
185
373
  return t
186
- ? json(res, 200, { ...summary(t), tail: t.tail.slice(-60) })
374
+ ? json(res, 200, { ...summary(t), tail: (t.tail ?? []).slice(-120) })
187
375
  : json(res, 404, { error: 'not found' });
188
376
  }
189
377
  const stop = url.pathname.match(/^\/tasks\/([\w-]+)\/stop$/);
190
378
  if (req.method === 'POST' && stop) {
191
379
  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.';
380
+ if (t?.child && t.alive) {
381
+ try {
382
+ t.child.stdin.end();
383
+ t.child.kill('SIGTERM');
384
+ } catch {
385
+ /* ignore */
386
+ }
387
+ if (t.status === 'running') {
388
+ t.status = 'stopped';
389
+ t.endedAt = Date.now();
390
+ t.lastText = 'Stopped from the panel.';
391
+ }
392
+ saveHistory();
197
393
  }
198
394
  return json(res, 200, { ok: true });
199
395
  }
396
+ const message = url.pathname.match(/^\/tasks\/([\w-]+)\/message$/);
397
+ if (req.method === 'POST' && message) {
398
+ const t = tasks.get(message[1]);
399
+ if (!t) return json(res, 404, { error: 'not found' });
400
+ const body = await readBody(req);
401
+ if (!body.text) return json(res, 400, { error: 'text required' });
402
+ try {
403
+ sendMessage(t, String(body.text));
404
+ return json(res, 200, summary(t));
405
+ } catch (err) {
406
+ return json(res, 409, { error: err instanceof Error ? err.message : String(err) });
407
+ }
408
+ }
409
+ const model = url.pathname.match(/^\/tasks\/([\w-]+)\/model$/);
410
+ if (req.method === 'POST' && model) {
411
+ const t = tasks.get(model[1]);
412
+ if (!t) return json(res, 404, { error: 'not found' });
413
+ const body = await readBody(req);
414
+ // Takes effect on the NEXT message (resume respawn) — killing an
415
+ // in-flight turn to switch models would lose work.
416
+ t.pendingModel = body.model ? String(body.model) : null;
417
+ if (!t.alive && t.pendingModel) {
418
+ t.model = t.pendingModel;
419
+ t.pendingModel = null;
420
+ }
421
+ saveHistory();
422
+ return json(res, 200, summary(t));
423
+ }
200
424
  if (req.method === 'POST' && url.pathname === '/tasks') {
201
425
  const body = await readBody(req);
202
426
  if (!body.prompt) return json(res, 400, { error: 'prompt required' });
203
427
  const task = startTask(body);
204
428
  return json(res, 201, summary(task));
205
429
  }
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.
430
+ // Upload proxy: browsers can't PUT to Linear's storage (no CORS there) —
431
+ // this local process can. SSRF-guarded to Linear storage hosts.
209
432
  if (req.method === 'POST' && url.pathname === '/put') {
210
433
  const target = String(req.headers['x-upload-url'] ?? '');
211
434
  let host = '';
@@ -241,5 +464,6 @@ createServer(async (req, res) => {
241
464
  console.log(`linear-grab bridge v${VERSION}`);
242
465
  console.log(` repo: ${DIR}`);
243
466
  console.log(` listen: http://127.0.0.1:${PORT} (localhost only)`);
244
- console.log(` tasks run: ${CLAUDE_BIN} -p --permission-mode acceptEdits`);
467
+ console.log(` tasks run: ${CLAUDE_BIN} -p (interactive stream-json, acceptEdits)`);
468
+ loadHistory();
245
469
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linear-grab-bridge",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {