claude-bridge-cli 2.0.11 → 2.0.17
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/README.md +74 -74
- package/bin/cli.js +391 -391
- package/lib/bridge.js +1127 -1019
- package/lib/relay-client.js +152 -152
- package/package.json +34 -26
package/lib/bridge.js
CHANGED
|
@@ -1,1019 +1,1127 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const http = require("node:http");
|
|
4
|
-
const { spawn, execSync } = require("node:child_process");
|
|
5
|
-
const fs = require("node:fs");
|
|
6
|
-
const path = require("node:path");
|
|
7
|
-
const crypto = require("node:crypto");
|
|
8
|
-
const os = require("node:os");
|
|
9
|
-
|
|
10
|
-
const running = new Map();
|
|
11
|
-
|
|
12
|
-
function homeDir() {
|
|
13
|
-
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
let
|
|
373
|
-
try {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
return
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
if (
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if (
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
m
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http = require("node:http");
|
|
4
|
+
const { spawn, execSync } = require("node:child_process");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const os = require("node:os");
|
|
9
|
+
|
|
10
|
+
const running = new Map();
|
|
11
|
+
|
|
12
|
+
function homeDir() {
|
|
13
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── MCP auto-allow ───────────────────────────────────────────────────────────
|
|
17
|
+
// Headless `claude -p` has no interactive permission prompt, so any tool not in
|
|
18
|
+
// `permissions.allow` is auto-DENIED ("Claude requested permissions to use
|
|
19
|
+
// mcp__x__y, but you haven't granted it yet"). MCP servers the user configured
|
|
20
|
+
// in their CLI therefore LOAD fine (claude mcp list shows "✓ Connected") but
|
|
21
|
+
// every call fails — which reads as "MCP doesn't work through the extension".
|
|
22
|
+
// Verified against a probe server: allow ["mcp__probe"] works; ["mcp__*"] does
|
|
23
|
+
// NOT (no wildcard support), so real server names must be enumerated.
|
|
24
|
+
// Opt out with CLAUDE_BRIDGE_MCP_AUTOALLOW=0.
|
|
25
|
+
function discoverMcpServers(cwd) {
|
|
26
|
+
const names = new Set();
|
|
27
|
+
const base = process.env.CLAUDE_CONFIG_DIR || homeDir();
|
|
28
|
+
try {
|
|
29
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(base, ".claude.json"), "utf8"));
|
|
30
|
+
for (const k of Object.keys(cfg.mcpServers || {})) names.add(k);
|
|
31
|
+
const projects = cfg.projects || {};
|
|
32
|
+
if (cwd && projects[cwd]) {
|
|
33
|
+
for (const k of Object.keys(projects[cwd].mcpServers || {})) names.add(k);
|
|
34
|
+
} else {
|
|
35
|
+
// Unknown/!matching cwd: union every project's local servers so a session
|
|
36
|
+
// resumed in any directory still gets its tools granted.
|
|
37
|
+
for (const p of Object.values(projects)) {
|
|
38
|
+
for (const k of Object.keys((p && p.mcpServers) || {})) names.add(k);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
if (cwd) {
|
|
43
|
+
try {
|
|
44
|
+
const proj = JSON.parse(fs.readFileSync(path.join(cwd, ".mcp.json"), "utf8"));
|
|
45
|
+
for (const k of Object.keys(proj.mcpServers || {})) names.add(k);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return [...names].filter(Boolean).sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withMcpAllow(allow, cwd) {
|
|
52
|
+
if (process.env.CLAUDE_BRIDGE_MCP_AUTOALLOW === "0") return allow;
|
|
53
|
+
const out = [...allow];
|
|
54
|
+
for (const name of discoverMcpServers(cwd)) {
|
|
55
|
+
const rule = "mcp__" + name;
|
|
56
|
+
if (!out.includes(rule)) out.push(rule);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dataDir() {
|
|
62
|
+
const d = path.join(homeDir(), ".claude-bridge");
|
|
63
|
+
fs.mkdirSync(d, { recursive: true });
|
|
64
|
+
return d;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJson(filePath, def) {
|
|
68
|
+
try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return def; }
|
|
69
|
+
}
|
|
70
|
+
function writeJson(filePath, data) {
|
|
71
|
+
const tmp = filePath + ".tmp";
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
73
|
+
fs.renameSync(tmp, filePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectsDir() {
|
|
77
|
+
return path.join(homeDir(), ".claude", "projects");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Persisted turn-completion markers ──────────────────────────────────────
|
|
81
|
+
// A turn is "complete" ONLY when its claude process actually resolved with a
|
|
82
|
+
// parseable result. We persist that fact to disk (keyed by session id) so the
|
|
83
|
+
// completion signal survives a bridge restart. Without it, getSessionMessages
|
|
84
|
+
// falls back to the in-memory `running` set — which is wiped on restart — and
|
|
85
|
+
// then promotes the trailing JSONL assistant line to `final_text`. For a turn
|
|
86
|
+
// orphaned by a restart, that trailing line is often a mid-turn tool-call
|
|
87
|
+
// preamble ("Running the type-check: I'll report once…") which then gets
|
|
88
|
+
// mis-rendered as the final answer. The marker lets us tell genuine completion
|
|
89
|
+
// apart from an interrupted/orphaned turn.
|
|
90
|
+
function completionDir() {
|
|
91
|
+
const d = path.join(dataDir(), "completions");
|
|
92
|
+
fs.mkdirSync(d, { recursive: true });
|
|
93
|
+
return d;
|
|
94
|
+
}
|
|
95
|
+
function completionMarkerPath(sid) {
|
|
96
|
+
return path.join(completionDir(), encodeURIComponent(sid) + ".json");
|
|
97
|
+
}
|
|
98
|
+
function writeCompletionMarker(sid, finalText) {
|
|
99
|
+
if (!sid || sid === "pending") return;
|
|
100
|
+
try {
|
|
101
|
+
writeJson(completionMarkerPath(sid), {
|
|
102
|
+
session_id: sid,
|
|
103
|
+
stop_reason: "end_turn",
|
|
104
|
+
final_text: typeof finalText === "string" ? finalText : "",
|
|
105
|
+
completed_at: Date.now(),
|
|
106
|
+
});
|
|
107
|
+
} catch {}
|
|
108
|
+
// Best-effort prune of stale markers (>30d) so the dir can't grow forever.
|
|
109
|
+
try {
|
|
110
|
+
const dir = completionDir();
|
|
111
|
+
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
|
|
112
|
+
for (const f of fs.readdirSync(dir)) {
|
|
113
|
+
const fp = path.join(dir, f);
|
|
114
|
+
try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
function readCompletionMarker(sid) {
|
|
119
|
+
if (!sid) return null;
|
|
120
|
+
return readJson(completionMarkerPath(sid), null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Image handling ──
|
|
124
|
+
|
|
125
|
+
const ALLOWED_IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
|
|
126
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
127
|
+
|
|
128
|
+
function sanitizeFilename(name, fallbackExt = "png") {
|
|
129
|
+
let base = path.basename(name || "image").replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
130
|
+
const ext = path.extname(base).slice(1).toLowerCase();
|
|
131
|
+
if (!ext || !ALLOWED_IMAGE_EXTS.has(ext)) base += "." + fallbackExt;
|
|
132
|
+
return base.slice(0, 200);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function saveImages(images, sessionId) {
|
|
136
|
+
if (!Array.isArray(images) || !images.length) return [];
|
|
137
|
+
const dir = path.join(dataDir(), "images", sessionId || "unsorted");
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
const saved = [];
|
|
140
|
+
const ts = Date.now();
|
|
141
|
+
for (let i = 0; i < images.length; i++) {
|
|
142
|
+
const img = images[i];
|
|
143
|
+
if (!img || !img.data_base64) continue;
|
|
144
|
+
const buf = Buffer.from(img.data_base64, "base64");
|
|
145
|
+
if (buf.length > MAX_IMAGE_BYTES) continue;
|
|
146
|
+
const name = sanitizeFilename(img.name || `image-${i}.png`);
|
|
147
|
+
const filename = `${ts}-${i}-${name}`;
|
|
148
|
+
const filePath = path.join(dir, filename);
|
|
149
|
+
fs.writeFileSync(filePath, buf, { mode: 0o600 });
|
|
150
|
+
saved.push(filePath);
|
|
151
|
+
}
|
|
152
|
+
return saved;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Per-session file exchange (the Files drawer) ──────────────────────────
|
|
156
|
+
// Same per-session folder used for images doubles as a two-way file drawer:
|
|
157
|
+
// the user uploads ANY file here (Claude reads it), and Claude drops files here
|
|
158
|
+
// (the user downloads them). Old files pruned on access.
|
|
159
|
+
const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
|
160
|
+
const FILE_PRUNE_DAYS = 14;
|
|
161
|
+
const FILE_NAME_RE = /^[A-Za-z0-9._ ()+\-]+$/;
|
|
162
|
+
const EXT_MIME = {
|
|
163
|
+
drawio: "application/xml", pdf: "application/pdf", json: "application/json",
|
|
164
|
+
csv: "text/csv", txt: "text/plain", md: "text/markdown", xml: "application/xml",
|
|
165
|
+
svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
166
|
+
gif: "image/gif", webp: "image/webp", zip: "application/zip", gz: "application/gzip",
|
|
167
|
+
tar: "application/x-tar", html: "text/html", css: "text/css", js: "text/javascript",
|
|
168
|
+
ts: "text/plain", py: "text/x-python", cs: "text/plain", java: "text/x-java",
|
|
169
|
+
yml: "text/yaml", yaml: "text/yaml", sql: "text/plain", log: "text/plain", sh: "text/x-sh",
|
|
170
|
+
};
|
|
171
|
+
function mimeFor(name) {
|
|
172
|
+
const e = path.extname(name || "").slice(1).toLowerCase();
|
|
173
|
+
return EXT_MIME[e] || "application/octet-stream";
|
|
174
|
+
}
|
|
175
|
+
function sanitizeAnyFilename(name) {
|
|
176
|
+
let base = path.basename((name || "").trim())
|
|
177
|
+
.replace(/[^A-Za-z0-9._ ()+\-]/g, "_")
|
|
178
|
+
.replace(/^[._ ]+|[._ ]+$/g, "");
|
|
179
|
+
return (base || "upload.bin").slice(0, 200);
|
|
180
|
+
}
|
|
181
|
+
function sessionFilesDir(sid) {
|
|
182
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sid || "")) throw new Error("bad session id");
|
|
183
|
+
return path.join(dataDir(), "images", sid);
|
|
184
|
+
}
|
|
185
|
+
function pruneSessionFiles(dir) {
|
|
186
|
+
const cutoff = Date.now() - FILE_PRUNE_DAYS * 86400 * 1000;
|
|
187
|
+
let entries; try { entries = fs.readdirSync(dir); } catch { return; }
|
|
188
|
+
for (const f of entries) {
|
|
189
|
+
const fp = path.join(dir, f);
|
|
190
|
+
try { const st = fs.statSync(fp); if (st.isFile() && st.mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function listSessionFiles(sid) {
|
|
194
|
+
const dir = sessionFilesDir(sid);
|
|
195
|
+
pruneSessionFiles(dir);
|
|
196
|
+
let entries; try { entries = fs.readdirSync(dir); } catch { return []; }
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const f of entries.sort()) {
|
|
199
|
+
const fp = path.join(dir, f);
|
|
200
|
+
try {
|
|
201
|
+
const st = fs.statSync(fp);
|
|
202
|
+
if (st.isFile()) out.push({ name: f, size: st.size, mtime: Math.floor(st.mtimeMs / 1000), mime: mimeFor(f) });
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function readSessionFile(sid, name) {
|
|
208
|
+
const dir = sessionFilesDir(sid);
|
|
209
|
+
const safe = path.basename(name || "");
|
|
210
|
+
if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
|
|
211
|
+
const fp = path.join(dir, safe);
|
|
212
|
+
if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
|
|
213
|
+
if (fs.statSync(fp).size > MAX_FILE_BYTES) throw new Error("file exceeds limit — too large to transfer");
|
|
214
|
+
const buf = fs.readFileSync(fp);
|
|
215
|
+
return { name: safe, size: buf.length, mime: mimeFor(safe), data_base64: buf.toString("base64") };
|
|
216
|
+
}
|
|
217
|
+
function saveSessionFile(sid, name, dataB64) {
|
|
218
|
+
const dir = sessionFilesDir(sid);
|
|
219
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
220
|
+
pruneSessionFiles(dir);
|
|
221
|
+
if (typeof dataB64 !== "string") throw new Error("missing data_base64");
|
|
222
|
+
const mm = dataB64.match(/^data:[^;]+;base64,([\s\S]+)$/);
|
|
223
|
+
if (mm) dataB64 = mm[1];
|
|
224
|
+
const buf = Buffer.from(dataB64, "base64");
|
|
225
|
+
if (buf.length > MAX_FILE_BYTES) throw new Error("file too large");
|
|
226
|
+
const final = `${Date.now()}-${sanitizeAnyFilename(name || "upload.bin")}`;
|
|
227
|
+
const fp = path.join(dir, final);
|
|
228
|
+
fs.writeFileSync(fp, buf, { mode: 0o600 });
|
|
229
|
+
return { name: final, size: buf.length, mime: mimeFor(final), path: fp };
|
|
230
|
+
}
|
|
231
|
+
function deleteSessionFile(sid, name) {
|
|
232
|
+
const dir = sessionFilesDir(sid);
|
|
233
|
+
const safe = path.basename(name || "");
|
|
234
|
+
if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
|
|
235
|
+
const fp = path.join(dir, safe);
|
|
236
|
+
if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
|
|
237
|
+
fs.unlinkSync(fp);
|
|
238
|
+
return { deleted: safe };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function splitUserTextAndImages(text) {
|
|
242
|
+
const m = text.match(/\nThe user attached \d+ image\(s\) at these absolute paths\. Use the Read tool to view them:\n([\s\S]+)$/);
|
|
243
|
+
if (!m) return { cleanText: text, imagePaths: [] };
|
|
244
|
+
const cleanText = text.slice(0, m.index).trimEnd();
|
|
245
|
+
const paths = m[1].split("\n").map(l => l.replace(/^- /, "").trim()).filter(Boolean);
|
|
246
|
+
const imageData = [];
|
|
247
|
+
for (const p of paths) {
|
|
248
|
+
try {
|
|
249
|
+
const buf = fs.readFileSync(p);
|
|
250
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
251
|
+
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg"
|
|
252
|
+
: ext === "gif" ? "image/gif"
|
|
253
|
+
: ext === "webp" ? "image/webp"
|
|
254
|
+
: "image/png";
|
|
255
|
+
imageData.push({ name: path.basename(p), data_base64: buf.toString("base64"), mime });
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
return { cleanText, imagePaths: paths, imageData };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Session scanning ──
|
|
262
|
+
|
|
263
|
+
function scanSessionFiles() {
|
|
264
|
+
const base = projectsDir();
|
|
265
|
+
if (!fs.existsSync(base)) return [];
|
|
266
|
+
const results = [];
|
|
267
|
+
let dirs;
|
|
268
|
+
try { dirs = fs.readdirSync(base); } catch { return []; }
|
|
269
|
+
for (const project of dirs) {
|
|
270
|
+
const projDir = path.join(base, project);
|
|
271
|
+
let stat;
|
|
272
|
+
try { stat = fs.statSync(projDir); } catch { continue; }
|
|
273
|
+
if (!stat.isDirectory()) continue;
|
|
274
|
+
let files;
|
|
275
|
+
try { files = fs.readdirSync(projDir); } catch { continue; }
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
278
|
+
const id = file.replace(".jsonl", "");
|
|
279
|
+
const filePath = path.join(projDir, file);
|
|
280
|
+
results.push({ id, project, filePath });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return results;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function looksLikeInternal(text) {
|
|
287
|
+
if (!text) return false;
|
|
288
|
+
const t = text.slice(0, 500);
|
|
289
|
+
return /^\s*\{/.test(t) && /"tool_use_id"|"tool_result"|"is_error"/.test(t);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseSessionFile(filePath) {
|
|
293
|
+
let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "", cwd = "";
|
|
294
|
+
try {
|
|
295
|
+
// For listing, read just the first 64KB and the last 64KB.
|
|
296
|
+
// First chunk: get preview + ai_title from early events.
|
|
297
|
+
// Last chunk: get last_prompt and custom-title overrides (latest wins).
|
|
298
|
+
// Message count is approximated from file size.
|
|
299
|
+
const stat = fs.statSync(filePath);
|
|
300
|
+
const CHUNK = 64 * 1024;
|
|
301
|
+
const fd = fs.openSync(filePath, "r");
|
|
302
|
+
try {
|
|
303
|
+
const headBuf = Buffer.alloc(Math.min(CHUNK, stat.size));
|
|
304
|
+
fs.readSync(fd, headBuf, 0, headBuf.length, 0);
|
|
305
|
+
const head = headBuf.toString("utf8");
|
|
306
|
+
for (const line of head.split("\n")) {
|
|
307
|
+
if (!line) continue;
|
|
308
|
+
try {
|
|
309
|
+
const obj = JSON.parse(line);
|
|
310
|
+
// The real working directory is recorded on the session's events;
|
|
311
|
+
// capture the first one. Far more accurate than decoding the project
|
|
312
|
+
// dir name (which is lossy — it can't recover ":" or distinguish a
|
|
313
|
+
// path separator from a literal "-", e.g. Windows "C:\GIT\…").
|
|
314
|
+
if (!cwd && typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
|
|
315
|
+
if (obj.type === "summary" && obj.summary) preview = preview || obj.summary.slice(0, 200);
|
|
316
|
+
if (obj.type === "user" && obj.message?.content) {
|
|
317
|
+
const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
|
|
318
|
+
if (!preview && !looksLikeInternal(text)) preview = text.slice(0, 200);
|
|
319
|
+
}
|
|
320
|
+
if (obj.type === "result" && obj.result?.metadata?.title?.value) aiTitle = aiTitle || obj.result.metadata.title.value;
|
|
321
|
+
if (obj.type === "ai-title") aiTitle = obj.aiTitle || obj.title || aiTitle;
|
|
322
|
+
if (obj.type === "custom-title") customTitle = obj.customTitle || obj.title || customTitle;
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
if (stat.size > CHUNK) {
|
|
326
|
+
const tailBuf = Buffer.alloc(CHUNK);
|
|
327
|
+
fs.readSync(fd, tailBuf, 0, CHUNK, stat.size - CHUNK);
|
|
328
|
+
const tail = tailBuf.toString("utf8");
|
|
329
|
+
const tailLines = tail.split("\n");
|
|
330
|
+
if (tailLines.length > 1) tailLines.shift();
|
|
331
|
+
for (const line of tailLines) {
|
|
332
|
+
if (!line) continue;
|
|
333
|
+
try {
|
|
334
|
+
const obj = JSON.parse(line);
|
|
335
|
+
if (obj.type === "user" && obj.message?.content) {
|
|
336
|
+
const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
|
|
337
|
+
if (!looksLikeInternal(text)) lastPrompt = text.slice(0, 200);
|
|
338
|
+
}
|
|
339
|
+
if (obj.type === "ai-title") aiTitle = obj.aiTitle || obj.title || aiTitle;
|
|
340
|
+
if (obj.type === "custom-title") customTitle = obj.customTitle || obj.title || customTitle;
|
|
341
|
+
} catch {}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Count newlines accurately by streaming chunks.
|
|
345
|
+
const COUNT_BUF = Buffer.alloc(64 * 1024);
|
|
346
|
+
let pos = 0;
|
|
347
|
+
while (pos < stat.size) {
|
|
348
|
+
const got = fs.readSync(fd, COUNT_BUF, 0, COUNT_BUF.length, pos);
|
|
349
|
+
if (got <= 0) break;
|
|
350
|
+
for (let i = 0; i < got; i++) if (COUNT_BUF[i] === 0x0A) msgCount++;
|
|
351
|
+
pos += got;
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
fs.closeSync(fd);
|
|
355
|
+
}
|
|
356
|
+
} catch {}
|
|
357
|
+
return { preview, ai_title: customTitle || aiTitle, message_count: msgCount, last_prompt: lastPrompt, cwd };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function projectDirToCwd(name) {
|
|
361
|
+
return name.replace(/-/g, "/");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function listSessions(opts = {}) {
|
|
365
|
+
const dd = dataDir();
|
|
366
|
+
const files = scanSessionFiles();
|
|
367
|
+
const titleOverrides = readJson(path.join(dd, "title-overrides.json"), {});
|
|
368
|
+
const starred = new Set(readJson(path.join(dd, "starred.json"), []));
|
|
369
|
+
const sessions = [];
|
|
370
|
+
|
|
371
|
+
for (const { id, project, filePath } of files) {
|
|
372
|
+
let stat;
|
|
373
|
+
try { stat = fs.statSync(filePath); } catch { continue; }
|
|
374
|
+
const parsed = parseSessionFile(filePath);
|
|
375
|
+
if (!opts.includeTiny && parsed.message_count < 3) continue;
|
|
376
|
+
if (opts.project && project !== opts.project) continue;
|
|
377
|
+
const title = titleOverrides[id] || parsed.ai_title;
|
|
378
|
+
sessions.push({
|
|
379
|
+
id, project,
|
|
380
|
+
cwd: parsed.cwd || projectDirToCwd(project),
|
|
381
|
+
mtime: stat.mtimeMs / 1000,
|
|
382
|
+
mtime_iso: stat.mtime.toISOString(),
|
|
383
|
+
preview: parsed.preview,
|
|
384
|
+
ai_title: title,
|
|
385
|
+
message_count: parsed.message_count,
|
|
386
|
+
last_prompt: parsed.last_prompt,
|
|
387
|
+
size_bytes: stat.size,
|
|
388
|
+
starred: starred.has(id),
|
|
389
|
+
in_progress: running.has(id),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
sessions.sort((a, b) => (b.starred ? 1 : 0) - (a.starred ? 1 : 0) || b.mtime - a.mtime);
|
|
394
|
+
return sessions.slice(0, opts.limit || 400);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Search ──
|
|
398
|
+
|
|
399
|
+
function searchSessions(query, limit = 30) {
|
|
400
|
+
if (!query || query.length < 2) return [];
|
|
401
|
+
const q = query.toLowerCase();
|
|
402
|
+
const dd = dataDir();
|
|
403
|
+
const titleOverrides = readJson(path.join(dd, "title-overrides.json"), {});
|
|
404
|
+
const starred = new Set(readJson(path.join(dd, "starred.json"), []));
|
|
405
|
+
const files = scanSessionFiles();
|
|
406
|
+
|
|
407
|
+
files.sort((a, b) => {
|
|
408
|
+
try {
|
|
409
|
+
return fs.statSync(b.filePath).mtimeMs - fs.statSync(a.filePath).mtimeMs;
|
|
410
|
+
} catch { return 0; }
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const results = [];
|
|
414
|
+
for (const { id, project, filePath } of files.slice(0, 200)) {
|
|
415
|
+
let stat;
|
|
416
|
+
try { stat = fs.statSync(filePath); } catch { continue; }
|
|
417
|
+
let content;
|
|
418
|
+
try { content = fs.readFileSync(filePath, "utf8"); } catch { continue; }
|
|
419
|
+
|
|
420
|
+
const lower = content.toLowerCase();
|
|
421
|
+
const idx = lower.indexOf(q);
|
|
422
|
+
if (idx < 0) continue;
|
|
423
|
+
|
|
424
|
+
const matchCount = lower.split(q).length - 1;
|
|
425
|
+
const snippetStart = Math.max(0, idx - 40);
|
|
426
|
+
const snippetEnd = Math.min(content.length, idx + q.length + 80);
|
|
427
|
+
const snippet = content.slice(snippetStart, snippetEnd).replace(/\n/g, " ").trim();
|
|
428
|
+
|
|
429
|
+
let aiTitle = "", msgCount = 0, realCwd = "";
|
|
430
|
+
try {
|
|
431
|
+
const lines = content.split("\n").filter(Boolean);
|
|
432
|
+
msgCount = lines.length;
|
|
433
|
+
for (const line of lines) {
|
|
434
|
+
try {
|
|
435
|
+
const obj = JSON.parse(line);
|
|
436
|
+
if (!realCwd && typeof obj.cwd === "string" && obj.cwd) realCwd = obj.cwd;
|
|
437
|
+
if (obj.type === "result" && obj.result?.metadata?.title?.value) {
|
|
438
|
+
aiTitle = obj.result.metadata.title.value;
|
|
439
|
+
}
|
|
440
|
+
if (obj.type === "ai-title" && obj.title) aiTitle = obj.title;
|
|
441
|
+
if (obj.type === "custom-title" && obj.title) aiTitle = obj.title;
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
} catch {}
|
|
445
|
+
|
|
446
|
+
results.push({
|
|
447
|
+
id, project,
|
|
448
|
+
cwd: realCwd || projectDirToCwd(project),
|
|
449
|
+
mtime: stat.mtimeMs / 1000,
|
|
450
|
+
mtime_iso: stat.mtime.toISOString(),
|
|
451
|
+
ai_title: titleOverrides[id] || aiTitle,
|
|
452
|
+
snippet,
|
|
453
|
+
match_count: matchCount,
|
|
454
|
+
message_count: msgCount,
|
|
455
|
+
starred: starred.has(id),
|
|
456
|
+
});
|
|
457
|
+
if (results.length >= limit) break;
|
|
458
|
+
}
|
|
459
|
+
return results;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Session messages ──
|
|
463
|
+
|
|
464
|
+
const INTERNAL_USER_PATTERNS = [
|
|
465
|
+
"<system-reminder>", "<command-name>", "<command-message>",
|
|
466
|
+
"<local-command-stdout>", "<command-stderr>", "<local-command-stderr>",
|
|
467
|
+
"Caveat: The messages below were generated by",
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
function extractUserText(content) {
|
|
471
|
+
// Only extract type:"text" parts. tool_result records don't have a text
|
|
472
|
+
// part so they return empty string and get filtered out.
|
|
473
|
+
let raw = "";
|
|
474
|
+
if (typeof content === "string") raw = content;
|
|
475
|
+
else if (Array.isArray(content)) {
|
|
476
|
+
for (const part of content) {
|
|
477
|
+
if (part && typeof part === "object" && part.type === "text") {
|
|
478
|
+
raw = part.text || "";
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return resolveAtFileRefs(raw);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// When the bridge sends a multi-line prompt to claude on Windows it writes
|
|
487
|
+
// the prompt to a temp file and passes "@C:\...\claude-prompt-NNN.txt" as
|
|
488
|
+
// the -p arg. claude records that literal @path in the session JSONL as
|
|
489
|
+
// the user message. When we read the session back we want to show the
|
|
490
|
+
// actual content, not the path — otherwise history looks like a wall of
|
|
491
|
+
// temp-file paths. If the file still exists on disk, inline its contents;
|
|
492
|
+
// otherwise leave the @path alone so the user at least sees something.
|
|
493
|
+
function resolveAtFileRefs(text) {
|
|
494
|
+
if (!text || typeof text !== "string") return text;
|
|
495
|
+
// Match a leading @<path> that points at a claude-prompt-*.txt temp file.
|
|
496
|
+
// Restrict to the temp-file pattern we generate ourselves — never inline
|
|
497
|
+
// arbitrary user-typed @file references (which are a real Claude Code
|
|
498
|
+
// feature that should remain as-is).
|
|
499
|
+
const trimmed = text.trim();
|
|
500
|
+
if (!trimmed.startsWith("@")) return text;
|
|
501
|
+
const m = trimmed.match(/^@(.+?claude-prompt-\d+-[a-z0-9]+\.txt)\s*$/i);
|
|
502
|
+
if (!m) return text;
|
|
503
|
+
const filePath = m[1];
|
|
504
|
+
try {
|
|
505
|
+
if (fs.existsSync(filePath)) {
|
|
506
|
+
const body = fs.readFileSync(filePath, "utf8");
|
|
507
|
+
if (body && body.length) return body;
|
|
508
|
+
}
|
|
509
|
+
} catch {}
|
|
510
|
+
// Legacy: the temp file was already cleaned up (pre-2.0.13 behavior deleted
|
|
511
|
+
// them at turn end) — show an honest placeholder instead of a confusing path.
|
|
512
|
+
return "(your message text is unavailable — it was passed via a temp file that was cleaned up; fixed for new messages in bridge 2.0.13)";
|
|
513
|
+
return text;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function shouldSkipUserText(text) {
|
|
517
|
+
if (!text) return true;
|
|
518
|
+
const t = text.replace(/^\s+/, "");
|
|
519
|
+
if (INTERNAL_USER_PATTERNS.some(p => t.startsWith(p))) return true;
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function getSessionMessages(sessionId) {
|
|
524
|
+
const base = projectsDir();
|
|
525
|
+
let dirs;
|
|
526
|
+
try { dirs = fs.readdirSync(base); } catch { return { error: "session not found" }; }
|
|
527
|
+
for (const project of dirs) {
|
|
528
|
+
const filePath = path.join(base, project, sessionId + ".jsonl");
|
|
529
|
+
if (!fs.existsSync(filePath)) continue;
|
|
530
|
+
const messages = [];
|
|
531
|
+
for (const line of fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean)) {
|
|
532
|
+
try {
|
|
533
|
+
const obj = JSON.parse(line);
|
|
534
|
+
// Skip transcript-only and meta records (replays, recaps)
|
|
535
|
+
if (obj.isMeta === true) continue;
|
|
536
|
+
if (obj.isVisibleInTranscriptOnly === true) continue;
|
|
537
|
+
if (obj.type === "user" && obj.message?.content) {
|
|
538
|
+
const text = extractUserText(obj.message.content);
|
|
539
|
+
if (shouldSkipUserText(text)) continue;
|
|
540
|
+
const split = splitUserTextAndImages(text);
|
|
541
|
+
messages.push({
|
|
542
|
+
role: "user",
|
|
543
|
+
text: split.cleanText,
|
|
544
|
+
images: split.imageData || [],
|
|
545
|
+
timestamp: obj.timestamp,
|
|
546
|
+
});
|
|
547
|
+
} else if (obj.type === "assistant" && obj.message?.content) {
|
|
548
|
+
const parts = Array.isArray(obj.message.content) ? obj.message.content : [obj.message.content];
|
|
549
|
+
const text = parts.map(p => typeof p === "string" ? p : p.text || "").join("");
|
|
550
|
+
if (!text) continue;
|
|
551
|
+
// Merge consecutive assistant turns into one (tool_use cycles).
|
|
552
|
+
// Track the trailing event's stop_reason — `end_turn`/`stop_sequence`
|
|
553
|
+
// means the turn finished; `tool_use` means it stopped on a tool-call
|
|
554
|
+
// preamble (i.e. mid-action). This is the authoritative, in-transcript
|
|
555
|
+
// completion signal and works for every session, old or new.
|
|
556
|
+
const sr = obj.message?.stop_reason || null;
|
|
557
|
+
const mdl = obj.message?.model || null; // claude records the model per reply
|
|
558
|
+
if (messages.length && messages[messages.length - 1].role === "assistant") {
|
|
559
|
+
messages[messages.length - 1].text = (messages[messages.length - 1].text + "\n" + text).trim();
|
|
560
|
+
messages[messages.length - 1].timestamp = obj.timestamp || messages[messages.length - 1].timestamp;
|
|
561
|
+
messages[messages.length - 1]._stop_reason = sr;
|
|
562
|
+
if (mdl) messages[messages.length - 1].model = mdl;
|
|
563
|
+
} else {
|
|
564
|
+
messages.push({ role: "assistant", text, timestamp: obj.timestamp, _stop_reason: sr, model: mdl });
|
|
565
|
+
}
|
|
566
|
+
} else if (obj.type === "result" && obj.result?.assistantMessage) {
|
|
567
|
+
const text = typeof obj.result.assistantMessage === "string" ? obj.result.assistantMessage : "";
|
|
568
|
+
if (text) messages.push({ role: "assistant", text, timestamp: obj.timestamp });
|
|
569
|
+
}
|
|
570
|
+
} catch {}
|
|
571
|
+
}
|
|
572
|
+
// Match the Python bridge's shape so the app's reconnect/resume loader can
|
|
573
|
+
// detect completion. Without `status` (and end-of-turn markers on the
|
|
574
|
+
// trailing assistant), the loader never finalizes — it shows "still working"
|
|
575
|
+
// forever even though Claude is done (the stuck-loader bug on this bridge).
|
|
576
|
+
const inProgress = running.has(sessionId);
|
|
577
|
+
const last_event_ts = messages.length ? (messages[messages.length - 1].timestamp || null) : null;
|
|
578
|
+
let status = inProgress ? "in_progress" : "complete";
|
|
579
|
+
let interrupted = false;
|
|
580
|
+
if (!inProgress && messages.length) {
|
|
581
|
+
const last = messages[messages.length - 1];
|
|
582
|
+
// Completion is decided by two independent signals, either of which is
|
|
583
|
+
// sufficient:
|
|
584
|
+
// 1. Transcript: the trailing assistant event's stop_reason is
|
|
585
|
+
// `end_turn`/`stop_sequence` (claude closed the turn). Authoritative
|
|
586
|
+
// and present in every session, so old sessions stay "complete".
|
|
587
|
+
// 2. Marker: a persisted completion record exists AND was written no
|
|
588
|
+
// earlier than the trailing event (nothing newer ran after it).
|
|
589
|
+
// If NEITHER holds — e.g. the trailing line is a `tool_use` preamble and
|
|
590
|
+
// no fresh marker exists because the bridge was restarted mid-turn — the
|
|
591
|
+
// turn was orphaned. We must NOT fabricate an end_turn on that line.
|
|
592
|
+
const transcriptDone = last._stop_reason === "end_turn" || last._stop_reason === "stop_sequence";
|
|
593
|
+
const marker = readCompletionMarker(sessionId);
|
|
594
|
+
const lastTs = last_event_ts ? Date.parse(last_event_ts) : 0;
|
|
595
|
+
const markerGenuine = marker && (!lastTs || lastTs <= (marker.completed_at + 2000));
|
|
596
|
+
const genuine = transcriptDone || markerGenuine;
|
|
597
|
+
if (last.role === "assistant" && last.text) {
|
|
598
|
+
if (genuine) {
|
|
599
|
+
last.stop_reason = "end_turn";
|
|
600
|
+
last.final_text = last.text;
|
|
601
|
+
} else {
|
|
602
|
+
// Interrupted/orphaned: finalize honestly instead of passing the
|
|
603
|
+
// trailing preamble off as the answer (and instead of leaving the
|
|
604
|
+
// client's loader spinning forever with no end marker).
|
|
605
|
+
status = "interrupted";
|
|
606
|
+
interrupted = true;
|
|
607
|
+
last.interrupted = true;
|
|
608
|
+
last.stop_reason = "interrupted";
|
|
609
|
+
last.final_text = last.text +
|
|
610
|
+
"\n\n⚠️ This turn was interrupted before it finished (the bridge was " +
|
|
611
|
+
"likely restarted mid-turn). The text above may be a partial step, not " +
|
|
612
|
+
"the final result. Reply \"continue\" to resume.";
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
for (const m of messages) delete m._stop_reason; // internal-only signal
|
|
617
|
+
return {
|
|
618
|
+
session_id: sessionId,
|
|
619
|
+
messages,
|
|
620
|
+
in_progress: inProgress,
|
|
621
|
+
status,
|
|
622
|
+
interrupted,
|
|
623
|
+
last_event_ts,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
return { error: "session not found" };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── Session delete / wipe ──
|
|
630
|
+
|
|
631
|
+
function deleteSession(sessionId) {
|
|
632
|
+
const base = projectsDir();
|
|
633
|
+
let dirs;
|
|
634
|
+
try { dirs = fs.readdirSync(base); } catch { return { error: "not found" }; }
|
|
635
|
+
for (const project of dirs) {
|
|
636
|
+
const filePath = path.join(base, project, sessionId + ".jsonl");
|
|
637
|
+
if (!fs.existsSync(filePath)) continue;
|
|
638
|
+
fs.unlinkSync(filePath);
|
|
639
|
+
const imgDir = path.join(dataDir(), "images", sessionId);
|
|
640
|
+
try { fs.rmSync(imgDir, { recursive: true, force: true }); } catch {}
|
|
641
|
+
return { deleted: true, session_id: sessionId };
|
|
642
|
+
}
|
|
643
|
+
return { error: "not found" };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function wipeAllSessions() {
|
|
647
|
+
const base = projectsDir();
|
|
648
|
+
let removed = 0;
|
|
649
|
+
try {
|
|
650
|
+
for (const project of fs.readdirSync(base)) {
|
|
651
|
+
const projDir = path.join(base, project);
|
|
652
|
+
if (!fs.statSync(projDir).isDirectory()) continue;
|
|
653
|
+
for (const file of fs.readdirSync(projDir)) {
|
|
654
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
655
|
+
try { fs.unlinkSync(path.join(projDir, file)); removed++; } catch {}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} catch {}
|
|
659
|
+
const dd = dataDir();
|
|
660
|
+
for (const f of ["marks.json", "bindings.json", "title-overrides.json", "starred.json"]) {
|
|
661
|
+
try { fs.unlinkSync(path.join(dd, f)); } catch {}
|
|
662
|
+
}
|
|
663
|
+
try { fs.rmSync(path.join(dd, "images"), { recursive: true, force: true }); } catch {}
|
|
664
|
+
return { removed_jsonls: removed };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Session rename ──
|
|
668
|
+
|
|
669
|
+
function renameSession(sessionId, title) {
|
|
670
|
+
const dd = dataDir();
|
|
671
|
+
const overrides = readJson(path.join(dd, "title-overrides.json"), {});
|
|
672
|
+
overrides[sessionId] = (title || "").slice(0, 500);
|
|
673
|
+
writeJson(path.join(dd, "title-overrides.json"), overrides);
|
|
674
|
+
return { ok: true, title: overrides[sessionId] };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ── Run claude -p ──
|
|
678
|
+
|
|
679
|
+
// Injected into every turn (--append-system-prompt) so Claude never ends a turn
|
|
680
|
+
// promising async follow-up it can't keep: a bridge turn is one-shot and atomic —
|
|
681
|
+
// nothing re-invokes Claude after it stops, so "I'll report once the build
|
|
682
|
+
// completes" is a promise that never resolves (it leaves the UI dead-ended).
|
|
683
|
+
const ATOMIC_TURN_PROMPT =
|
|
684
|
+
"You are running inside a one-shot, non-interactive bridge: THIS TURN IS ATOMIC. " +
|
|
685
|
+
"It ends the moment you stop producing output, and nothing re-invokes you afterward. " +
|
|
686
|
+
"You cannot do work in the background, report back later, be re-triggered when an " +
|
|
687
|
+
"external job (build/CI/deploy/long command) finishes, or continue on your own. " +
|
|
688
|
+
"Therefore NEVER end a turn by promising future follow-up such as 'I'll report once " +
|
|
689
|
+
"it completes', 'I'll continue when CI is done', or 'waiting on X to finish'. Instead, " +
|
|
690
|
+
"run any work to completion within this turn and report the actual result now. If a " +
|
|
691
|
+
"task genuinely cannot finish in this turn, say so plainly and tell the user the exact " +
|
|
692
|
+
"command(s) to run or the next message to send to continue — do not imply you will resume.";
|
|
693
|
+
|
|
694
|
+
function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_tools, model, fork }) {
|
|
695
|
+
return new Promise((resolve) => {
|
|
696
|
+
let finalPrompt = prompt;
|
|
697
|
+
if (images && images.length) {
|
|
698
|
+
const saved = saveImages(images, session_id || "pending");
|
|
699
|
+
if (saved.length) {
|
|
700
|
+
finalPrompt += `\nThe user attached ${saved.length} image(s) at these absolute paths. Use the Read tool to view them:\n` +
|
|
701
|
+
saved.map(p => `- ${p}`).join("\n");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const isWin = process.platform === "win32";
|
|
706
|
+
const isCmdShim = isWin && config.claudeBin.endsWith(".cmd");
|
|
707
|
+
|
|
708
|
+
// Pass the prompt via a temp file (@file, read by claude itself) instead of as
|
|
709
|
+
// a command-line argument when:
|
|
710
|
+
// 1) Prompt exceeds Windows cmd.exe's ~8K arg limit (threshold 6000).
|
|
711
|
+
// 2) We're on Windows with a .cmd shim — period. The shim runs through
|
|
712
|
+
// `cmd.exe /c`, which RE-PARSES the prompt with CMD's own rules: a double
|
|
713
|
+
// quote (") or a metacharacter (% & | < > ^) breaks the command line and
|
|
714
|
+
// surfaces as the cryptic "The system cannot find the file specified";
|
|
715
|
+
// a newline silently truncates the prompt at line 1. Node's argv quoting
|
|
716
|
+
// can't protect against cmd.exe's second parse. Passing @file sidesteps ALL
|
|
717
|
+
// of it, so do it for EVERY cmd-shim prompt — not just multi-line ones.
|
|
718
|
+
// (Previously only triggered on \r|\n, so quoted single-line prompts failed.)
|
|
719
|
+
const useTempFile =
|
|
720
|
+
finalPrompt.length > 6000 ||
|
|
721
|
+
(isWin && isCmdShim);
|
|
722
|
+
let tmpFile = null;
|
|
723
|
+
if (useTempFile) {
|
|
724
|
+
// PERSISTENT prompts dir — NOT os.tmpdir(). The session JSONL records the
|
|
725
|
+
// literal "@<path>" as the user message, and getSessionMessages resolves
|
|
726
|
+
// it by reading the file back. Deleting the file after the turn (the old
|
|
727
|
+
// behavior) made every reload show the raw @C:\...\claude-prompt-*.txt
|
|
728
|
+
// path instead of the user's text. Files are pruned after 30 days.
|
|
729
|
+
const promptsDir = path.join(dataDir(), "prompts");
|
|
730
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
731
|
+
try {
|
|
732
|
+
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
|
|
733
|
+
for (const f of fs.readdirSync(promptsDir)) {
|
|
734
|
+
const fp = path.join(promptsDir, f);
|
|
735
|
+
try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
|
|
736
|
+
}
|
|
737
|
+
} catch {}
|
|
738
|
+
tmpFile = path.join(promptsDir, `claude-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
|
|
739
|
+
fs.writeFileSync(tmpFile, finalPrompt, "utf8");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Allow list — the caller (extension) can override via `allow_tools`, e.g.
|
|
743
|
+
// after the user clicks "Allow <tool>" on a permission denial, so an MCP
|
|
744
|
+
// tool (mcp__server__tool) or any other non-default tool can be granted for
|
|
745
|
+
// the session. Falls back to the default working set. Mirrors the Python
|
|
746
|
+
// bridge so both behave identically.
|
|
747
|
+
const defaultAllow = ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "WebFetch", "WebSearch",
|
|
748
|
+
"Task", "TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "NotebookEdit"];
|
|
749
|
+
const allow = (Array.isArray(allow_tools) && allow_tools.length &&
|
|
750
|
+
allow_tools.every(x => typeof x === "string")) ? allow_tools : defaultAllow;
|
|
751
|
+
const settings = JSON.stringify({
|
|
752
|
+
permissions: {
|
|
753
|
+
defaultMode: "acceptEdits",
|
|
754
|
+
allow,
|
|
755
|
+
deny: ["AskUserQuestion"],
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
const promptArg = useTempFile ? `@${tmpFile}` : finalPrompt;
|
|
759
|
+
const args = ["-p", promptArg, "--output-format", "json",
|
|
760
|
+
"--settings", settings, "--permission-mode", "acceptEdits",
|
|
761
|
+
"--append-system-prompt", ATOMIC_TURN_PROMPT];
|
|
762
|
+
let resumeCwd = null;
|
|
763
|
+
if (session_id) {
|
|
764
|
+
const existing = scanSessionFiles().find(s => s.id === session_id);
|
|
765
|
+
if (existing) {
|
|
766
|
+
args.push("--resume", session_id);
|
|
767
|
+
// Fork: replay this session's history into a BRAND-NEW session id instead
|
|
768
|
+
// of appending to the original. claude returns the new id in the result,
|
|
769
|
+
// which the client adopts (leaving the original untouched). --fork-session
|
|
770
|
+
// only works alongside --resume.
|
|
771
|
+
if (fork) args.push("--fork-session");
|
|
772
|
+
// Scan JSONL for the first event with a cwd field
|
|
773
|
+
try {
|
|
774
|
+
const lines = fs.readFileSync(existing.filePath, "utf8").split("\n");
|
|
775
|
+
for (const line of lines) {
|
|
776
|
+
if (!line) continue;
|
|
777
|
+
try {
|
|
778
|
+
const parsed = JSON.parse(line);
|
|
779
|
+
if (parsed.cwd) { resumeCwd = parsed.cwd; break; }
|
|
780
|
+
} catch {}
|
|
781
|
+
}
|
|
782
|
+
} catch {}
|
|
783
|
+
} else {
|
|
784
|
+
args.push("--session-id", session_id);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (plan_mode) args.push("--plan");
|
|
788
|
+
// GLOBAL SKILLS. Verified against this CLI: skills in ~/.claude/skills are
|
|
789
|
+
// NOT discovered (not by the Skill tool, not as /slash, not even with
|
|
790
|
+
// --setting-sources user), while a skill inside a PLUGIN dir loads from any
|
|
791
|
+
// cwd. So personal always-on skills live in <data>/global-skills as a plugin
|
|
792
|
+
// and are attached to every turn — global instead of per-project.
|
|
793
|
+
try {
|
|
794
|
+
const gsDir = process.env.CLAUDE_BRIDGE_GLOBAL_SKILLS_DIR ||
|
|
795
|
+
path.join(dataDir(), "global-skills");
|
|
796
|
+
if (fs.existsSync(path.join(gsDir, "skills"))) args.push("--plugin-dir", gsDir);
|
|
797
|
+
} catch {}
|
|
798
|
+
// Optional model override from the extension's model picker (alias or id).
|
|
799
|
+
if (model && String(model).trim()) args.push("--model", String(model).trim());
|
|
800
|
+
|
|
801
|
+
// Effective cwd is only final here (resumeCwd comes from the session JSONL),
|
|
802
|
+
// and local-scope MCP servers are keyed BY directory — so grant MCP against
|
|
803
|
+
// the real run cwd, patching the already-built --settings in place.
|
|
804
|
+
const effCwd = cwd || resumeCwd || config.cwd;
|
|
805
|
+
try {
|
|
806
|
+
const mcpAllow = withMcpAllow(allow, effCwd);
|
|
807
|
+
if (mcpAllow.length !== allow.length) {
|
|
808
|
+
const si = args.indexOf("--settings");
|
|
809
|
+
if (si !== -1) {
|
|
810
|
+
const s = JSON.parse(args[si + 1]);
|
|
811
|
+
s.permissions.allow = mcpAllow;
|
|
812
|
+
args[si + 1] = JSON.stringify(s);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
} catch {}
|
|
816
|
+
|
|
817
|
+
const bin = isCmdShim ? process.env.COMSPEC || "cmd.exe" : config.claudeBin;
|
|
818
|
+
const fullArgs = isCmdShim ? ["/c", config.claudeBin, ...args] : args;
|
|
819
|
+
|
|
820
|
+
const proc = spawn(bin, fullArgs, {
|
|
821
|
+
cwd: effCwd,
|
|
822
|
+
timeout: config.timeout,
|
|
823
|
+
env: { ...process.env },
|
|
824
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
825
|
+
windowsHide: true,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
let stdout = "", stderr = "";
|
|
829
|
+
proc.stdout.on("data", (d) => { stdout += d; });
|
|
830
|
+
proc.stderr.on("data", (d) => { stderr += d; });
|
|
831
|
+
|
|
832
|
+
if (session_id) running.set(session_id, proc);
|
|
833
|
+
|
|
834
|
+
proc.on("close", (code) => {
|
|
835
|
+
// tmpFile is intentionally KEPT — history rendering reads it back (see promptsDir note).
|
|
836
|
+
if (session_id) running.delete(session_id);
|
|
837
|
+
try {
|
|
838
|
+
const result = JSON.parse(stdout);
|
|
839
|
+
const sid = result.session_id || session_id || crypto.randomUUID();
|
|
840
|
+
// Persist genuine completion so it survives a bridge restart. This is
|
|
841
|
+
// the ONLY place a clean end-of-turn is recorded — the error/partial
|
|
842
|
+
// branch below deliberately writes no marker, so an interrupted turn
|
|
843
|
+
// stays distinguishable.
|
|
844
|
+
const finalText = result.result || result.assistantMessage || "";
|
|
845
|
+
writeCompletionMarker(sid, finalText);
|
|
846
|
+
if (session_id && session_id !== sid) writeCompletionMarker(session_id, finalText);
|
|
847
|
+
if (images && images.length && session_id === "pending" && sid !== "pending") {
|
|
848
|
+
const oldDir = path.join(dataDir(), "images", "pending");
|
|
849
|
+
const newDir = path.join(dataDir(), "images", sid);
|
|
850
|
+
try { fs.renameSync(oldDir, newDir); } catch {}
|
|
851
|
+
}
|
|
852
|
+
// Surface tool-permission denials so the extension can show an "Allow
|
|
853
|
+
// <tool>" button (then resend with allow_tools including it). Without
|
|
854
|
+
// this, a tool that needs approval — notably any MCP tool, which isn't
|
|
855
|
+
// in the default allow list — silently fails with no way to grant it in
|
|
856
|
+
// headless -p mode. Mirrors the Python bridge's shape.
|
|
857
|
+
const rawDenials = Array.isArray(result.permission_denials) ? result.permission_denials : [];
|
|
858
|
+
const permission_denials = rawDenials
|
|
859
|
+
.filter(x => x && typeof x === "object")
|
|
860
|
+
.map(x => ({ tool_name: x.tool_name || "?", tool_input: x.tool_input || {} }));
|
|
861
|
+
resolve({
|
|
862
|
+
response: result.result || result.assistantMessage || stdout.slice(0, 5000),
|
|
863
|
+
session_id: sid,
|
|
864
|
+
cost_usd: result.cost_usd || null,
|
|
865
|
+
duration_ms: result.duration_ms || null,
|
|
866
|
+
context: result.context || null,
|
|
867
|
+
permission_denials,
|
|
868
|
+
});
|
|
869
|
+
} catch {
|
|
870
|
+
if (stdout.trim()) {
|
|
871
|
+
resolve({ response: stdout.trim(), session_id: session_id || crypto.randomUUID() });
|
|
872
|
+
} else {
|
|
873
|
+
resolve({ error: stderr.trim() || `claude exited with code ${code}` });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
proc.on("error", (err) => {
|
|
879
|
+
// tmpFile is intentionally KEPT — history rendering reads it back (see promptsDir note).
|
|
880
|
+
if (session_id) running.delete(session_id);
|
|
881
|
+
resolve({ error: err.message });
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ── Stop session ──
|
|
887
|
+
|
|
888
|
+
function stopSession(sessionId) {
|
|
889
|
+
const proc = running.get(sessionId);
|
|
890
|
+
if (!proc) return { stopped: false, reason: "not_running" };
|
|
891
|
+
if (process.platform === "win32" && proc.pid) {
|
|
892
|
+
try { execSync(`taskkill /f /t /pid ${proc.pid}`, { stdio: "ignore" }); } catch {}
|
|
893
|
+
} else {
|
|
894
|
+
try { proc.kill("SIGINT"); } catch {}
|
|
895
|
+
setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, 3000);
|
|
896
|
+
}
|
|
897
|
+
running.delete(sessionId);
|
|
898
|
+
return { stopped: true };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ── HTTP server ──
|
|
902
|
+
|
|
903
|
+
function startBridge(config) {
|
|
904
|
+
const dd = dataDir();
|
|
905
|
+
|
|
906
|
+
const server = http.createServer(async (req, res) => {
|
|
907
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
908
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
909
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Confirm-Wipe");
|
|
910
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
911
|
+
|
|
912
|
+
const send = (code, obj) => {
|
|
913
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
914
|
+
res.end(JSON.stringify(obj));
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const auth = req.headers.authorization || "";
|
|
918
|
+
if (req.url !== "/health" && auth !== `Bearer ${config.bearerToken}`) {
|
|
919
|
+
send(401, { error: "unauthorized" });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
924
|
+
let m;
|
|
925
|
+
|
|
926
|
+
// Health
|
|
927
|
+
if (url.pathname === "/health") {
|
|
928
|
+
send(200, { ok: true }); return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Ask
|
|
932
|
+
if (url.pathname === "/ask" && req.method === "POST") {
|
|
933
|
+
const body = await readBody(req);
|
|
934
|
+
const result = await askClaude(config, body);
|
|
935
|
+
send(result.error ? 500 : 200, result);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Sessions list
|
|
940
|
+
if (url.pathname === "/sessions" && req.method === "GET") {
|
|
941
|
+
const includeTiny = url.searchParams.get("include_tiny") === "1";
|
|
942
|
+
const project = url.searchParams.get("project") || "";
|
|
943
|
+
const sessions = listSessions({ includeTiny, project });
|
|
944
|
+
send(200, { sessions });
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Search
|
|
949
|
+
if (url.pathname === "/sessions/search" && req.method === "GET") {
|
|
950
|
+
const q = url.searchParams.get("q") || "";
|
|
951
|
+
const limit = parseInt(url.searchParams.get("limit") || "30", 10);
|
|
952
|
+
const results = searchSessions(q, limit);
|
|
953
|
+
send(200, { results });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Starred list
|
|
958
|
+
if (url.pathname === "/sessions/starred" && req.method === "GET") {
|
|
959
|
+
send(200, { starred: readJson(path.join(dd, "starred.json"), []) });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Wipe all sessions
|
|
964
|
+
if (url.pathname === "/sessions/all" && req.method === "DELETE") {
|
|
965
|
+
if (req.headers["x-confirm-wipe"] !== "yes-i-am-sure") {
|
|
966
|
+
send(400, { error: "missing X-Confirm-Wipe header" });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
send(200, wipeAllSessions());
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Session messages
|
|
974
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/messages$/);
|
|
975
|
+
if (m && req.method === "GET") {
|
|
976
|
+
const result = getSessionMessages(m[1]);
|
|
977
|
+
send(result.error ? 404 : 200, result);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Star toggle
|
|
982
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/star$/);
|
|
983
|
+
if (m && req.method === "POST") {
|
|
984
|
+
const starred = readJson(path.join(dd, "starred.json"), []);
|
|
985
|
+
const sid = m[1];
|
|
986
|
+
const idx = starred.indexOf(sid);
|
|
987
|
+
if (idx >= 0) { starred.splice(idx, 1); writeJson(path.join(dd, "starred.json"), starred); send(200, { starred: false, session_id: sid }); }
|
|
988
|
+
else { starred.unshift(sid); writeJson(path.join(dd, "starred.json"), starred); send(200, { starred: true, session_id: sid }); }
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Stop
|
|
993
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/stop$/);
|
|
994
|
+
if (m && req.method === "POST") {
|
|
995
|
+
send(200, stopSession(m[1]));
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Marks
|
|
1000
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/marks$/);
|
|
1001
|
+
if (m) {
|
|
1002
|
+
const marksFile = path.join(dd, "marks.json");
|
|
1003
|
+
const allMarks = readJson(marksFile, {});
|
|
1004
|
+
if (req.method === "GET") {
|
|
1005
|
+
send(200, { marks: allMarks[m[1]] || {} });
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (req.method === "PATCH" || req.method === "POST") {
|
|
1009
|
+
const body = await readBody(req);
|
|
1010
|
+
const sess = allMarks[m[1]] || {};
|
|
1011
|
+
if (body.key && body.mark) {
|
|
1012
|
+
sess[body.key] = { ...(sess[body.key] || {}), ...body.mark };
|
|
1013
|
+
} else {
|
|
1014
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1015
|
+
sess[k] = { ...(sess[k] || {}), ...v };
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
allMarks[m[1]] = sess;
|
|
1019
|
+
writeJson(marksFile, allMarks);
|
|
1020
|
+
send(200, { ok: true });
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// AutoMode cross-device state + single-runner lease (per session, like marks)
|
|
1026
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/automode$/);
|
|
1027
|
+
if (m) {
|
|
1028
|
+
const amFile = path.join(dd, "automode-state.json");
|
|
1029
|
+
const allAm = readJson(amFile, {});
|
|
1030
|
+
if (req.method === "GET") {
|
|
1031
|
+
send(200, { automode: allAm[m[1]] || {} });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (req.method === "PATCH" || req.method === "POST") {
|
|
1035
|
+
const body = await readBody(req);
|
|
1036
|
+
const allowed = ["to_claude", "to_gpt", "gpt_ready", "claude_ready", "active_client", "active_ts"];
|
|
1037
|
+
const cur = allAm[m[1]] || {};
|
|
1038
|
+
for (const k of allowed) if (k in body) cur[k] = body[k];
|
|
1039
|
+
allAm[m[1]] = cur;
|
|
1040
|
+
writeJson(amFile, allAm);
|
|
1041
|
+
send(200, { automode: cur });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Title rename
|
|
1047
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/title$/);
|
|
1048
|
+
if (m && (req.method === "PATCH" || req.method === "POST")) {
|
|
1049
|
+
const body = await readBody(req);
|
|
1050
|
+
send(200, renameSession(m[1], body.title));
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Delete single session
|
|
1055
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)$/);
|
|
1056
|
+
if (m && req.method === "DELETE") {
|
|
1057
|
+
const result = deleteSession(m[1]);
|
|
1058
|
+
send(result.error ? 404 : 200, result);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ChatGPT bindings
|
|
1063
|
+
if (url.pathname === "/chatgpt-bindings") {
|
|
1064
|
+
const bindingsFile = path.join(dd, "bindings.json");
|
|
1065
|
+
if (req.method === "GET") {
|
|
1066
|
+
send(200, { bindings: readJson(bindingsFile, {}) });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (req.method === "POST") {
|
|
1070
|
+
const body = await readBody(req);
|
|
1071
|
+
const bindings = readJson(bindingsFile, {});
|
|
1072
|
+
if (body.conv_id) {
|
|
1073
|
+
if (body.session_id) bindings[body.conv_id] = body.session_id;
|
|
1074
|
+
else delete bindings[body.conv_id];
|
|
1075
|
+
writeJson(bindingsFile, bindings);
|
|
1076
|
+
}
|
|
1077
|
+
send(200, { bindings });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Session files — list (GET) / upload (POST)
|
|
1083
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files$/);
|
|
1084
|
+
if (m) {
|
|
1085
|
+
if (req.method === "GET") {
|
|
1086
|
+
try { send(200, { files: listSessionFiles(m[1]) }); } catch (e) { send(400, { error: String(e.message || e) }); }
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (req.method === "POST") {
|
|
1090
|
+
const body = await readBody(req);
|
|
1091
|
+
try { send(200, saveSessionFile(m[1], body.name, body.data_base64 || body.data)); }
|
|
1092
|
+
catch (e) { send(400, { error: String(e.message || e) }); }
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// Session files — download one (GET) / remove one (DELETE)
|
|
1097
|
+
m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files\/(.+)$/);
|
|
1098
|
+
if (m && req.method === "GET") {
|
|
1099
|
+
try { send(200, readSessionFile(m[1], decodeURIComponent(m[2]))); }
|
|
1100
|
+
catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (m && req.method === "DELETE") {
|
|
1104
|
+
try { send(200, deleteSessionFile(m[1], decodeURIComponent(m[2]))); }
|
|
1105
|
+
catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
send(404, { error: "not found" });
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
return new Promise((resolve) => {
|
|
1113
|
+
server.listen(config.port, config.host, () => resolve(server));
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function readBody(req) {
|
|
1118
|
+
return new Promise((resolve) => {
|
|
1119
|
+
let data = "";
|
|
1120
|
+
req.on("data", (c) => { data += c; });
|
|
1121
|
+
req.on("end", () => {
|
|
1122
|
+
try { resolve(JSON.parse(data)); } catch { resolve({}); }
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
module.exports = { startBridge };
|