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.
- package/linear-grab-bridge.mjs +283 -59
- package/package.json +1 -1
package/linear-grab-bridge.mjs
CHANGED
|
@@ -5,13 +5,20 @@
|
|
|
5
5
|
*
|
|
6
6
|
* npx linear-grab-bridge [--port 4577] [--dir .] [--claude claude]
|
|
7
7
|
*
|
|
8
|
-
* Each task
|
|
9
|
-
*
|
|
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.
|
|
25
|
-
const MAX_TAIL =
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
129
|
-
task.child = null;
|
|
215
|
+
saveHistory();
|
|
130
216
|
});
|
|
131
|
-
return task;
|
|
132
|
-
}
|
|
133
217
|
|
|
134
|
-
|
|
135
|
-
task.
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
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,
|
|
257
|
+
pushTail(task, 'assistant', p.text.trim().slice(0, 2000));
|
|
147
258
|
} else if (p.type === 'tool_use') {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(-
|
|
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.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
207
|
-
//
|
|
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
|
|
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.
|
|
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": {
|