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.
- package/linear-grab-bridge.mjs +357 -60
- 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
|
-
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.
|
|
25
|
-
|
|
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
|
|
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);
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
129
|
-
task.child = null;
|
|
226
|
+
saveHistory();
|
|
130
227
|
});
|
|
131
|
-
return task;
|
|
132
|
-
}
|
|
133
228
|
|
|
134
|
-
|
|
135
|
-
task.
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
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,
|
|
268
|
+
pushTail(task, 'assistant', p.text.trim().slice(0, 2000));
|
|
147
269
|
} else if (p.type === 'tool_use') {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(-
|
|
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.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
207
|
-
//
|
|
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
|
|
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.
|
|
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": {
|