@bakapiano/ccsm 0.10.3 → 0.11.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/CLAUDE.md +475 -475
- package/README.md +190 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliSessionWatcher.js +249 -249
- package/lib/config.js +185 -185
- package/lib/folders.js +96 -96
- package/lib/localCliSessions.js +489 -177
- package/lib/persistedSessions.js +134 -134
- package/lib/webTerminal.js +208 -208
- package/lib/workspace.js +230 -255
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -183
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +616 -601
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -79
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1596 -1375
- package/public/index.html +105 -103
- package/public/js/api.js +272 -260
- package/public/js/components/AdoptModal.js +343 -171
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +105 -105
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +270 -270
- package/public/js/components/TerminalView.js +298 -298
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +140 -140
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +475 -487
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +97 -97
- package/public/js/state.js +231 -231
- package/public/manifest.webmanifest +15 -15
- package/scripts/install.js +137 -137
- package/server.js +1126 -1117
package/server.js
CHANGED
|
@@ -1,1117 +1,1126 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const express = require('express');
|
|
6
|
-
|
|
7
|
-
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
8
|
-
const {
|
|
9
|
-
listWorkspaces,
|
|
10
|
-
findOrCreateWorkspace,
|
|
11
|
-
ensureReposInWorkspace,
|
|
12
|
-
isInside,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} catch {}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
res.setHeader('Access-Control-Allow-
|
|
58
|
-
res.setHeader('Access-Control-Allow-
|
|
59
|
-
res.setHeader('
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
app.get('/api/dev/
|
|
81
|
-
|
|
82
|
-
res.setHeader('
|
|
83
|
-
res.setHeader('
|
|
84
|
-
res.
|
|
85
|
-
res.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
e
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
cli.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
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
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
let
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
.
|
|
465
|
-
.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
{ label: '
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
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
|
-
return res.status(
|
|
533
|
-
}
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
res.
|
|
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
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (req.body && req.body.
|
|
589
|
-
const
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const
|
|
745
|
-
if (
|
|
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
|
-
let
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
upgradeInFlight
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
'C:\\Program Files
|
|
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
|
-
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const express = require('express');
|
|
6
|
+
|
|
7
|
+
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
8
|
+
const {
|
|
9
|
+
listWorkspaces,
|
|
10
|
+
findOrCreateWorkspace,
|
|
11
|
+
ensureReposInWorkspace,
|
|
12
|
+
isInside,
|
|
13
|
+
} = require('./lib/workspace');
|
|
14
|
+
const webTerminal = require('./lib/webTerminal');
|
|
15
|
+
const persistedSessions = require('./lib/persistedSessions');
|
|
16
|
+
const folders = require('./lib/folders');
|
|
17
|
+
const cliSessionWatcher = require('./lib/cliSessionWatcher');
|
|
18
|
+
const localCliSessions = require('./lib/localCliSessions');
|
|
19
|
+
|
|
20
|
+
// One unified exit path: kill PTY children, then exit. v1.0 dropped the
|
|
21
|
+
// snapshot-on-exit behaviour because the new persistedSessions store is
|
|
22
|
+
// the source of truth (and is always on disk, not in memory).
|
|
23
|
+
let shuttingDown = false;
|
|
24
|
+
async function gracefulShutdown(reason) {
|
|
25
|
+
if (shuttingDown) return;
|
|
26
|
+
shuttingDown = true;
|
|
27
|
+
console.log(`[ccsm] shutting down · ${reason}`);
|
|
28
|
+
// Mark all running sessions as exited (best-effort) so the next launch
|
|
29
|
+
// doesn't show stale "running" rows.
|
|
30
|
+
try {
|
|
31
|
+
const all = await persistedSessions.loadAll();
|
|
32
|
+
for (const s of all) {
|
|
33
|
+
if (s.status === 'running') {
|
|
34
|
+
await persistedSessions.markExited(s.id, null).catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
try { webTerminal.killAll(); } catch {}
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const app = express();
|
|
43
|
+
app.use(express.json({ limit: '1mb' }));
|
|
44
|
+
|
|
45
|
+
// CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
|
|
46
|
+
// open WebSockets. Listed explicitly — never reflect Origin or use '*' so
|
|
47
|
+
// random web pages can't reach the local backend. Localhost dev calls
|
|
48
|
+
// stay same-origin (browser doesn't add Origin header → middleware is a
|
|
49
|
+
// no-op for them).
|
|
50
|
+
const ALLOWED_ORIGINS = new Set([
|
|
51
|
+
'https://bakapiano.github.io',
|
|
52
|
+
]);
|
|
53
|
+
app.use((req, res, next) => {
|
|
54
|
+
const origin = req.headers.origin;
|
|
55
|
+
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
|
56
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
57
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
58
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
59
|
+
res.setHeader('Vary', 'Origin');
|
|
60
|
+
}
|
|
61
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
62
|
+
next();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Dev mode = running from a checkout (not from an npm-install location).
|
|
66
|
+
// Used to gate two things: (a) serving static frontend from local public/
|
|
67
|
+
// so a contributor can iterate without pushing to GH Pages; (b) hot-reload
|
|
68
|
+
// SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
|
|
69
|
+
// both explicitly. In production (npm-installed), backend is API-only —
|
|
70
|
+
// frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
|
|
71
|
+
const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
|
|
72
|
+
|
|
73
|
+
if (IS_DEV) {
|
|
74
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const reloadClients = new Set();
|
|
78
|
+
if (IS_DEV) {
|
|
79
|
+
app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
|
|
80
|
+
app.get('/api/dev/reload', (req, res) => {
|
|
81
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
82
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
83
|
+
res.setHeader('Connection', 'keep-alive');
|
|
84
|
+
res.flushHeaders();
|
|
85
|
+
res.write(': connected\n\n');
|
|
86
|
+
reloadClients.add(res);
|
|
87
|
+
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
|
|
88
|
+
req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
|
|
89
|
+
});
|
|
90
|
+
const publicDir = path.join(__dirname, 'public');
|
|
91
|
+
const fs = require('node:fs');
|
|
92
|
+
let debounce = null;
|
|
93
|
+
fs.watch(publicDir, { recursive: true }, (_event, filename) => {
|
|
94
|
+
clearTimeout(debounce);
|
|
95
|
+
debounce = setTimeout(() => {
|
|
96
|
+
if (reloadClients.size === 0) return;
|
|
97
|
+
console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
|
|
98
|
+
for (const r of reloadClients) {
|
|
99
|
+
try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
|
|
100
|
+
}
|
|
101
|
+
}, 80);
|
|
102
|
+
});
|
|
103
|
+
console.log('[dev] hot-reload watching public/');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function asyncH(fn) {
|
|
107
|
+
return (req, res) => {
|
|
108
|
+
Promise.resolve(fn(req, res)).catch((err) => {
|
|
109
|
+
console.error('[api error]', err);
|
|
110
|
+
res.status(500).json({ error: String(err && err.message || err) });
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- helpers ----
|
|
116
|
+
|
|
117
|
+
function pickCli(cfg, requestedId) {
|
|
118
|
+
const wanted = requestedId || cfg.defaultCliId;
|
|
119
|
+
return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Resolve how to spawn a CLI command. Windows quirks:
|
|
123
|
+
// v1.1 — spawn strategy is now caller-controlled via cli.shell:
|
|
124
|
+
// 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
|
|
125
|
+
// Won't find pwsh aliases / functions.
|
|
126
|
+
// 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
|
|
127
|
+
// Loads $PROFILE → pwsh aliases / functions (`ccp`, `cxp`) work.
|
|
128
|
+
// Falls back to powershell.exe (5.x) if pwsh.exe absent.
|
|
129
|
+
// 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
|
|
130
|
+
// and PATH-only names without pwsh dependency.
|
|
131
|
+
function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
|
|
132
|
+
if (!commandRaw) throw new Error('cli.command is empty');
|
|
133
|
+
const cmd = commandRaw.replace(/^\.[\\\/]/, '');
|
|
134
|
+
|
|
135
|
+
if (shell === 'pwsh') {
|
|
136
|
+
// Build a single -Command string so pwsh tokenizes args itself. The
|
|
137
|
+
// `& { ... }` wrapper makes pwsh execute the line as a script block —
|
|
138
|
+
// critical for functions (which aren't visible without invocation).
|
|
139
|
+
const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
|
|
140
|
+
return {
|
|
141
|
+
exe: 'pwsh.exe',
|
|
142
|
+
prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
|
|
143
|
+
fallbackExe: 'powershell.exe',
|
|
144
|
+
consumesUserArgs: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (shell === 'cmd') {
|
|
149
|
+
// /d skips AutoRun, /s preserves quoting, /c runs and exits.
|
|
150
|
+
const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
|
|
151
|
+
return {
|
|
152
|
+
exe: process.env.ComSpec || 'cmd.exe',
|
|
153
|
+
prefixArgs: ['/d', '/s', '/c', joined],
|
|
154
|
+
consumesUserArgs: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// shell === 'direct' — bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
|
|
159
|
+
// when an absolute path was provided so they still work without an
|
|
160
|
+
// explicit shell choice.
|
|
161
|
+
if (path.isAbsolute(cmd)) {
|
|
162
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
163
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
164
|
+
return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
|
|
165
|
+
}
|
|
166
|
+
if (ext === '.ps1') {
|
|
167
|
+
return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
|
|
168
|
+
}
|
|
169
|
+
return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
|
|
170
|
+
}
|
|
171
|
+
// Bare name with shell=direct: defer to cmd.exe so Windows resolves
|
|
172
|
+
// against PATH. Same behavior as before — preserves user expectations
|
|
173
|
+
// for `claude` / `codex` configs that don't set shell.
|
|
174
|
+
return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function quoteForPwsh(s) {
|
|
178
|
+
if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
|
|
179
|
+
return s;
|
|
180
|
+
}
|
|
181
|
+
function quoteForCmd(s) {
|
|
182
|
+
if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
|
|
187
|
+
if (!webTerminal.available) {
|
|
188
|
+
const e = new Error('node-pty unavailable · cannot spawn web terminal');
|
|
189
|
+
e.code = 'PTY_UNAVAILABLE';
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
// For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
|
|
193
|
+
// extraArgs into the single quoted command string — otherwise extraArgs
|
|
194
|
+
// would become args to the shell itself, not the wrapped command.
|
|
195
|
+
// Re-resolve here when extraArgs is present so the quoting is correct.
|
|
196
|
+
const resolved = resolveCommand(
|
|
197
|
+
cli.command,
|
|
198
|
+
[...(cli.args || []), ...extraArgs],
|
|
199
|
+
cli.shell || 'direct',
|
|
200
|
+
);
|
|
201
|
+
const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
|
|
202
|
+
const args = consumesUserArgs
|
|
203
|
+
? prefixArgs
|
|
204
|
+
: [...prefixArgs, ...(cli.args || []), ...extraArgs];
|
|
205
|
+
// Merge user-scope PATH from registry into the env we hand the PTY.
|
|
206
|
+
const env = { ...process.env, ...(cli.env || {}) };
|
|
207
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
208
|
+
const trySpawn = (executable) => webTerminal.spawn({
|
|
209
|
+
id: sessionId,
|
|
210
|
+
command: executable,
|
|
211
|
+
args,
|
|
212
|
+
cwd,
|
|
213
|
+
env,
|
|
214
|
+
meta: { ...meta, cliId: cli.id, cliName: cli.name },
|
|
215
|
+
onData: () => { persistedSessions.touch(sessionId).catch(() => {}); },
|
|
216
|
+
onExit: ({ exitCode }) => {
|
|
217
|
+
stopWatcher(sessionId);
|
|
218
|
+
persistedSessions.markExited(sessionId, exitCode).catch(() => {});
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
const entry = trySpawn(exe);
|
|
223
|
+
maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
|
|
224
|
+
return entry;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
|
|
227
|
+
const entry = trySpawn(fallbackExe);
|
|
228
|
+
maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
|
|
229
|
+
return entry;
|
|
230
|
+
}
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Start a fs-watch on the CLI's transcript directory so we can capture
|
|
236
|
+
// the upstream session UUID for later precise --resume. Only kicks off
|
|
237
|
+
// for CLI types we know how to watch (claude / codex / copilot).
|
|
238
|
+
//
|
|
239
|
+
// If the watcher times out (5 min with no transcript ever written), we
|
|
240
|
+
// assume the user closed the CLI before it persisted anything — so
|
|
241
|
+
// there's nothing to resume to and the ccsm record is dead weight. Drop
|
|
242
|
+
// the persistedSessions row and kill the PTY if it somehow lingers.
|
|
243
|
+
//
|
|
244
|
+
// IMPORTANT: if the record already has a captured cliSessionId (typical
|
|
245
|
+
// for `resume` and for `adopt`-imported records), skip the watcher
|
|
246
|
+
// entirely — there's nothing left to capture, and the timeout-cleanup
|
|
247
|
+
// would otherwise wipe a perfectly good record after 5 minutes of
|
|
248
|
+
// "no new transcript".
|
|
249
|
+
// Active upstream-session-id watchers, keyed by ccsm session id. We hold
|
|
250
|
+
// onto the cleanup fn returned by cliSessionWatcher so we can tear them
|
|
251
|
+
// down when the PTY exits or the record is deleted — a still-running
|
|
252
|
+
// watcher whose ccsm session is gone would otherwise match a *future*
|
|
253
|
+
// session that happens to spawn in the same cwd and stamp the wrong id
|
|
254
|
+
// onto a dead record (or worse, onto a re-created record reusing memory).
|
|
255
|
+
const activeWatchers = new Map(); // ccsmSessionId → cleanupFn
|
|
256
|
+
|
|
257
|
+
function stopWatcher(ccsmSessionId) {
|
|
258
|
+
const cleanup = activeWatchers.get(ccsmSessionId);
|
|
259
|
+
if (!cleanup) return;
|
|
260
|
+
activeWatchers.delete(ccsmSessionId);
|
|
261
|
+
try { cleanup(); } catch {}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function maybeWatchCliSessionId({ cli, cwd, ccsmSessionId }) {
|
|
265
|
+
if (!cli || !['claude', 'codex', 'copilot'].includes(cli.type)) return;
|
|
266
|
+
// If a previous watcher was still alive on this id (e.g. fast restart),
|
|
267
|
+
// tear it down first.
|
|
268
|
+
stopWatcher(ccsmSessionId);
|
|
269
|
+
try {
|
|
270
|
+
const existing = await persistedSessions.get(ccsmSessionId);
|
|
271
|
+
if (existing?.cliSessionId) {
|
|
272
|
+
console.log(`[cliSessionId] skip watcher · ${cli.type} session already known (${existing.cliSessionId})`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
const cleanup = cliSessionWatcher.captureSessionId({
|
|
277
|
+
cliType: cli.type,
|
|
278
|
+
cwd,
|
|
279
|
+
onCapture: (cliSessionId) => {
|
|
280
|
+
activeWatchers.delete(ccsmSessionId);
|
|
281
|
+
persistedSessions.update(ccsmSessionId, { cliSessionId }).catch((e) => {
|
|
282
|
+
console.error('[cliSessionId] save failed:', e.message);
|
|
283
|
+
});
|
|
284
|
+
console.log(`[cliSessionId] captured ${cli.type} session ${cliSessionId} for ccsm ${ccsmSessionId}`);
|
|
285
|
+
},
|
|
286
|
+
onTimeout: () => {
|
|
287
|
+
activeWatchers.delete(ccsmSessionId);
|
|
288
|
+
console.warn(`[cliSessionId] timeout · removing ccsm session ${ccsmSessionId} (no ${cli.type} transcript)`);
|
|
289
|
+
try { webTerminal.kill(ccsmSessionId); } catch {}
|
|
290
|
+
persistedSessions.remove(ccsmSessionId).catch((e) => {
|
|
291
|
+
console.error('[cliSessionId] remove failed:', e.message);
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
if (cleanup) activeWatchers.set(ccsmSessionId, cleanup);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Read user PATH from registry once at boot, prepend to process PATH.
|
|
299
|
+
// On platforms other than Windows or if the read fails, fall back to
|
|
300
|
+
// process.env.PATH unchanged.
|
|
301
|
+
let mergedUserPath = null;
|
|
302
|
+
function buildMergedUserPath() {
|
|
303
|
+
if (process.platform !== 'win32') return process.env.PATH;
|
|
304
|
+
try {
|
|
305
|
+
const { spawnSync } = require('node:child_process');
|
|
306
|
+
const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
|
|
307
|
+
if (r.status !== 0 || !r.stdout) return process.env.PATH;
|
|
308
|
+
const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
|
|
309
|
+
if (!line) return process.env.PATH;
|
|
310
|
+
const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
|
|
311
|
+
if (!m) return process.env.PATH;
|
|
312
|
+
// Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
|
|
313
|
+
const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
|
|
314
|
+
const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
|
|
315
|
+
const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
|
|
316
|
+
const merged = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
for (const p of [...adds, ...existing]) {
|
|
319
|
+
const k = p.toLowerCase();
|
|
320
|
+
if (seen.has(k)) continue;
|
|
321
|
+
seen.add(k);
|
|
322
|
+
merged.push(p);
|
|
323
|
+
}
|
|
324
|
+
return merged.join(';');
|
|
325
|
+
} catch {
|
|
326
|
+
return process.env.PATH;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
mergedUserPath = buildMergedUserPath();
|
|
330
|
+
|
|
331
|
+
// ---- config ----
|
|
332
|
+
|
|
333
|
+
// Per-CLI install probe. Looks up the command on PATH using `where` (win)
|
|
334
|
+
// or `which` (posix). Result is cached forever — restart ccsm after
|
|
335
|
+
// installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
|
|
336
|
+
const cliProbeCache = new Map();
|
|
337
|
+
function probeCli(command) {
|
|
338
|
+
if (!command) return null;
|
|
339
|
+
if (cliProbeCache.has(command)) return cliProbeCache.get(command);
|
|
340
|
+
const { spawnSync } = require('node:child_process');
|
|
341
|
+
let resolvedPath = null;
|
|
342
|
+
try {
|
|
343
|
+
const isWin = process.platform === 'win32';
|
|
344
|
+
const cmd = isWin ? 'where.exe' : 'which';
|
|
345
|
+
const env = { ...process.env };
|
|
346
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
347
|
+
const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
|
|
348
|
+
if (r.status === 0 && r.stdout) {
|
|
349
|
+
resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
cliProbeCache.set(command, resolvedPath);
|
|
353
|
+
return resolvedPath;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function decorateConfigWithProbes(cfg) {
|
|
357
|
+
return {
|
|
358
|
+
...cfg,
|
|
359
|
+
clis: (cfg.clis || []).map((c) => {
|
|
360
|
+
const path = probeCli(c.command);
|
|
361
|
+
return { ...c, installed: !!path, installPath: path };
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
app.get('/api/config', asyncH(async (_req, res) => {
|
|
367
|
+
res.json(decorateConfigWithProbes(await loadConfig()));
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
app.put('/api/config', asyncH(async (req, res) => {
|
|
371
|
+
const cfg = await saveConfig(req.body || {});
|
|
372
|
+
res.json(decorateConfigWithProbes(cfg));
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
// ---- CLIs ----
|
|
376
|
+
// ---- folders ----
|
|
377
|
+
|
|
378
|
+
app.get('/api/folders', asyncH(async (_req, res) => {
|
|
379
|
+
const list = await folders.loadAll();
|
|
380
|
+
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
381
|
+
res.json({ folders: list });
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
app.post('/api/folders', asyncH(async (req, res) => {
|
|
385
|
+
const name = req.body && req.body.name;
|
|
386
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
387
|
+
res.json({ folder: await folders.create({ name }) });
|
|
388
|
+
}));
|
|
389
|
+
|
|
390
|
+
app.put('/api/folders/:id', asyncH(async (req, res) => {
|
|
391
|
+
const updated = await folders.update(req.params.id, req.body || {});
|
|
392
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
393
|
+
res.json({ folder: updated });
|
|
394
|
+
}));
|
|
395
|
+
|
|
396
|
+
app.delete('/api/folders/:id', asyncH(async (req, res) => {
|
|
397
|
+
// Move all sessions in this folder to Unsorted before delete.
|
|
398
|
+
const all = await persistedSessions.loadAll();
|
|
399
|
+
for (const s of all) {
|
|
400
|
+
if (s.folderId === req.params.id) {
|
|
401
|
+
await persistedSessions.setFolder(s.id, null);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const removed = await folders.remove(req.params.id);
|
|
405
|
+
res.json({ removed });
|
|
406
|
+
}));
|
|
407
|
+
|
|
408
|
+
app.post('/api/folders/reorder', asyncH(async (req, res) => {
|
|
409
|
+
const ids = req.body && req.body.ids;
|
|
410
|
+
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
|
|
411
|
+
const next = await folders.reorder(ids);
|
|
412
|
+
res.json({ folders: next });
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
// ---- sessions (persisted, ccsm-owned) ----
|
|
416
|
+
|
|
417
|
+
app.get('/api/sessions', asyncH(async (_req, res) => {
|
|
418
|
+
const list = await persistedSessions.loadAll();
|
|
419
|
+
// Cross-check status against live PTY pool so a stale "running" record
|
|
420
|
+
// doesn't survive a server restart.
|
|
421
|
+
const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
|
|
422
|
+
for (const s of list) {
|
|
423
|
+
if (s.status === 'running' && !live.has(s.id)) {
|
|
424
|
+
s.status = 'exited';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
res.json({ sessions: list, takenAt: Date.now() });
|
|
428
|
+
}));
|
|
429
|
+
|
|
430
|
+
app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
431
|
+
const patch = {};
|
|
432
|
+
if (typeof req.body.title === 'string') patch.title = req.body.title;
|
|
433
|
+
if ('folderId' in (req.body || {})) patch.folderId = req.body.folderId || null;
|
|
434
|
+
const updated = await persistedSessions.update(req.params.id, patch);
|
|
435
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
436
|
+
res.json({ session: updated });
|
|
437
|
+
}));
|
|
438
|
+
|
|
439
|
+
app.delete('/api/sessions/:id', asyncH(async (req, res) => {
|
|
440
|
+
// Kill PTY first if it's still alive, then drop the record.
|
|
441
|
+
stopWatcher(req.params.id);
|
|
442
|
+
try { webTerminal.kill(req.params.id); } catch {}
|
|
443
|
+
const removed = await persistedSessions.remove(req.params.id);
|
|
444
|
+
res.json({ removed });
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
// ---- workspaces ----
|
|
448
|
+
|
|
449
|
+
// ---- directory browser ----
|
|
450
|
+
// Lets the launch picker walk the filesystem so users can pick any
|
|
451
|
+
// existing directory as the session cwd. Returns the immediate child
|
|
452
|
+
// dirs of `path` (defaults to home), plus a few hardcoded "starts"
|
|
453
|
+
// (home, workDir, drive roots on Windows).
|
|
454
|
+
app.get('/api/browse', asyncH(async (req, res) => {
|
|
455
|
+
const fs = require('node:fs/promises');
|
|
456
|
+
const os = require('node:os');
|
|
457
|
+
const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
|
|
458
|
+
let entries = [];
|
|
459
|
+
let exists = true;
|
|
460
|
+
try {
|
|
461
|
+
const list = await fs.readdir(target, { withFileTypes: true });
|
|
462
|
+
entries = list
|
|
463
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
464
|
+
.map((d) => ({ name: d.name, path: path.join(target, d.name) }))
|
|
465
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
466
|
+
} catch (e) {
|
|
467
|
+
exists = false;
|
|
468
|
+
}
|
|
469
|
+
const parent = path.dirname(target);
|
|
470
|
+
const cfg = await loadConfig();
|
|
471
|
+
const starts = [
|
|
472
|
+
{ label: 'Home', path: os.homedir() },
|
|
473
|
+
{ label: 'Work dir', path: cfg.workDir },
|
|
474
|
+
];
|
|
475
|
+
if (process.platform === 'win32') {
|
|
476
|
+
// Best-effort drive enumeration so users on D:\ etc can hop roots.
|
|
477
|
+
for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
|
|
478
|
+
const root = `${letter}:\\`;
|
|
479
|
+
try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
|
|
480
|
+
catch {}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
res.json({
|
|
484
|
+
path: target,
|
|
485
|
+
parent: parent === target ? null : parent,
|
|
486
|
+
exists,
|
|
487
|
+
entries,
|
|
488
|
+
starts,
|
|
489
|
+
});
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
app.get('/api/workspaces', asyncH(async (req, res) => {
|
|
493
|
+
const cfg = await loadConfig();
|
|
494
|
+
const workspaces = await listWorkspaces({
|
|
495
|
+
workDir: cfg.workDir,
|
|
496
|
+
repos: cfg.repos,
|
|
497
|
+
});
|
|
498
|
+
// Recompute inUse based on persistedSessions: a workspace is in use
|
|
499
|
+
// iff any RUNNING ccsm session lives at-or-inside it.
|
|
500
|
+
const allSess = await persistedSessions.loadAll();
|
|
501
|
+
const busy = new Set(
|
|
502
|
+
allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
|
|
503
|
+
);
|
|
504
|
+
for (const w of workspaces) {
|
|
505
|
+
w.inUse = busy.has(path.resolve(w.path).toLowerCase());
|
|
506
|
+
w.sessionsHere = allSess
|
|
507
|
+
.filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
|
|
508
|
+
.map((s) => s.id);
|
|
509
|
+
}
|
|
510
|
+
res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
// Delete a workspace directory. Refuses if any RUNNING session lives
|
|
514
|
+
// inside it, or if the resolved path escapes workDir. The name comes
|
|
515
|
+
// from the URL — we resolve it against workDir and verify containment.
|
|
516
|
+
app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
|
|
517
|
+
const fsp = require('node:fs/promises');
|
|
518
|
+
const cfg = await loadConfig();
|
|
519
|
+
const name = String(req.params.name || '');
|
|
520
|
+
// Reject anything that tries to escape via separators / traversal.
|
|
521
|
+
if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
|
|
522
|
+
return res.status(400).json({ error: 'invalid workspace name' });
|
|
523
|
+
}
|
|
524
|
+
const target = path.resolve(cfg.workDir, name);
|
|
525
|
+
if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
|
|
526
|
+
return res.status(400).json({ error: 'workspace must live under workDir' });
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const st = await fsp.stat(target);
|
|
530
|
+
if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
|
|
531
|
+
} catch {
|
|
532
|
+
return res.status(404).json({ error: 'workspace not found' });
|
|
533
|
+
}
|
|
534
|
+
const allSess = await persistedSessions.loadAll();
|
|
535
|
+
const inUse = allSess.some((s) =>
|
|
536
|
+
s.status === 'running' && isInside(s.cwd, target)
|
|
537
|
+
);
|
|
538
|
+
if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
|
|
539
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
540
|
+
res.json({ ok: true });
|
|
541
|
+
}));
|
|
542
|
+
|
|
543
|
+
// ---- new session ----
|
|
544
|
+
// body: { cliId?, repos?, workspace?, folderId?, launch?: true }
|
|
545
|
+
// Streams NDJSON: workspace / clone-* / launched / done.
|
|
546
|
+
app.post('/api/sessions/new', async (req, res) => {
|
|
547
|
+
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
548
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
549
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
550
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
551
|
+
|
|
552
|
+
const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
|
|
553
|
+
const fail = (msg, extra) => {
|
|
554
|
+
emit({ type: 'done', success: false, error: msg, ...extra });
|
|
555
|
+
res.end();
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const cfg = await loadConfig();
|
|
560
|
+
const cli = pickCli(cfg, req.body && req.body.cliId);
|
|
561
|
+
if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
|
|
562
|
+
|
|
563
|
+
const explicitRepos = Array.isArray(req.body && req.body.repos);
|
|
564
|
+
const wantedNames = explicitRepos
|
|
565
|
+
? req.body.repos
|
|
566
|
+
: cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
|
|
567
|
+
const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
|
|
568
|
+
if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
|
|
569
|
+
return fail('No matching repos found');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let workspace;
|
|
573
|
+
let created = false;
|
|
574
|
+
// Three cwd modes:
|
|
575
|
+
// 1. body.cwd — user picked an existing directory; skip clone.
|
|
576
|
+
// 2. body.workspace — reuse a named workspace under workDir.
|
|
577
|
+
// 3. (neither) — auto-allocate a fresh ws-N.
|
|
578
|
+
if (req.body && req.body.cwd) {
|
|
579
|
+
const fsmod = require('node:fs/promises');
|
|
580
|
+
const cwd = path.resolve(String(req.body.cwd));
|
|
581
|
+
try {
|
|
582
|
+
const st = await fsmod.stat(cwd);
|
|
583
|
+
if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
|
|
584
|
+
} catch {
|
|
585
|
+
return fail(`directory not found: ${cwd}`);
|
|
586
|
+
}
|
|
587
|
+
workspace = { name: path.basename(cwd) || cwd, path: cwd };
|
|
588
|
+
} else if (req.body && req.body.workspace) {
|
|
589
|
+
const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
|
|
590
|
+
workspace = all.find((w) => w.name === req.body.workspace);
|
|
591
|
+
if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
|
|
592
|
+
} else {
|
|
593
|
+
const r = await findOrCreateWorkspace({
|
|
594
|
+
workDir: cfg.workDir,
|
|
595
|
+
repos: cfg.repos,
|
|
596
|
+
requireUnused: true,
|
|
597
|
+
});
|
|
598
|
+
workspace = r.workspace;
|
|
599
|
+
created = r.created;
|
|
600
|
+
}
|
|
601
|
+
emit({ type: 'workspace', workspace, created });
|
|
602
|
+
|
|
603
|
+
// Skip clone entirely when user picked an existing directory — we
|
|
604
|
+
// don't want to dump random repos into someone's project.
|
|
605
|
+
const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
|
|
606
|
+
workspacePath: workspace.path,
|
|
607
|
+
repos: wantedRepos,
|
|
608
|
+
onRepoStart: (repo) =>
|
|
609
|
+
emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
|
|
610
|
+
onProgress: (repo, p) =>
|
|
611
|
+
emit({ type: 'clone-progress', repo: repo.name, ...p }),
|
|
612
|
+
onLine: (repo, line) =>
|
|
613
|
+
emit({ type: 'clone-line', repo: repo.name, line }),
|
|
614
|
+
onRepoEnd: (repo, result) =>
|
|
615
|
+
emit({ type: 'clone-end', repo: repo.name, ...result }),
|
|
616
|
+
});
|
|
617
|
+
const failed = cloneResults.filter((r) => !r.ok);
|
|
618
|
+
if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
|
|
619
|
+
|
|
620
|
+
const shouldLaunch = req.body && req.body.launch !== false;
|
|
621
|
+
let launched = null;
|
|
622
|
+
if (shouldLaunch) {
|
|
623
|
+
// Create the persistedSessions record FIRST so spawnCliSession can
|
|
624
|
+
// use its id as the PTY id (matching ids simplify resume/attach).
|
|
625
|
+
const record = await persistedSessions.create({
|
|
626
|
+
cliId: cli.id,
|
|
627
|
+
cwd: workspace.path,
|
|
628
|
+
workspace: workspace.name,
|
|
629
|
+
repos: wantedRepos.map((r) => r.name),
|
|
630
|
+
folderId: (req.body && req.body.folderId) || null,
|
|
631
|
+
title: '',
|
|
632
|
+
});
|
|
633
|
+
try {
|
|
634
|
+
const entry = spawnCliSession({
|
|
635
|
+
cli,
|
|
636
|
+
cwd: workspace.path,
|
|
637
|
+
sessionId: record.id,
|
|
638
|
+
meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
|
|
639
|
+
});
|
|
640
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
641
|
+
launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
|
|
642
|
+
emit({ type: 'launched', launched });
|
|
643
|
+
} catch (e) {
|
|
644
|
+
await persistedSessions.markExited(record.id, null);
|
|
645
|
+
return fail(`spawn failed: ${e.message}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
|
|
650
|
+
res.end();
|
|
651
|
+
} catch (e) {
|
|
652
|
+
console.error('[/api/sessions/new]', e);
|
|
653
|
+
fail(String(e && e.message || e));
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ---- list local CLI sessions discovered on disk (for "adopt") ----
|
|
658
|
+
// Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
|
|
659
|
+
// aren't yet adopted by ccsm. Frontend uses this in the Import modal.
|
|
660
|
+
app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
|
|
661
|
+
const type = String(req.params.cliType || '').toLowerCase();
|
|
662
|
+
if (!['claude', 'codex', 'copilot'].includes(type)) {
|
|
663
|
+
return res.status(400).json({ error: `unsupported cli type: ${type}` });
|
|
664
|
+
}
|
|
665
|
+
const offset = Math.max(0, Number(req.query.offset) || 0);
|
|
666
|
+
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
|
|
667
|
+
|
|
668
|
+
const [page, adopted] = await Promise.all([
|
|
669
|
+
localCliSessions.listPaginated(type, { offset, limit }),
|
|
670
|
+
persistedSessions.loadAll(),
|
|
671
|
+
]);
|
|
672
|
+
|
|
673
|
+
const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
|
|
674
|
+
const sessions = page.sessions.map((s) => ({
|
|
675
|
+
...s,
|
|
676
|
+
adopted: adoptedIds.has(s.cliSessionId),
|
|
677
|
+
}));
|
|
678
|
+
res.json({
|
|
679
|
+
sessions,
|
|
680
|
+
totalActive: page.totalActive,
|
|
681
|
+
totalNonActive: page.totalNonActive,
|
|
682
|
+
total: page.totalActive + page.totalNonActive,
|
|
683
|
+
offset: page.offset,
|
|
684
|
+
limit: page.limit,
|
|
685
|
+
hasMore: page.hasMore,
|
|
686
|
+
});
|
|
687
|
+
}));
|
|
688
|
+
|
|
689
|
+
// ---- adopt: create a ccsm record pointing at an existing CLI session ----
|
|
690
|
+
// Body: { cliId, cliSessionId, cwd, title?, folderId? }
|
|
691
|
+
// Doesn't spawn — the new entry shows up as "exited" in the sidebar;
|
|
692
|
+
// clicking it kicks off the regular resume flow which uses
|
|
693
|
+
// `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
|
|
694
|
+
app.post('/api/sessions/adopt', asyncH(async (req, res) => {
|
|
695
|
+
const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
|
|
696
|
+
if (!cliId || !cliSessionId || !cwd) {
|
|
697
|
+
return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
|
|
698
|
+
}
|
|
699
|
+
const cfg = await loadConfig();
|
|
700
|
+
const cli = pickCli(cfg, cliId);
|
|
701
|
+
if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
|
|
702
|
+
|
|
703
|
+
// Normalize the cwd up front. /api/sessions/new also resolves cwd, and
|
|
704
|
+
// the workspaces "in use" check (GET /api/workspaces) does
|
|
705
|
+
// path.resolve(s.cwd).toLowerCase() — adopted records must match the
|
|
706
|
+
// same shape, otherwise an adopted+running session leaves its
|
|
707
|
+
// workspace falsely marked as free and a fresh launch could collide.
|
|
708
|
+
const resolvedCwd = path.resolve(cwd);
|
|
709
|
+
try {
|
|
710
|
+
const fsmod = require('node:fs/promises');
|
|
711
|
+
const st = await fsmod.stat(resolvedCwd);
|
|
712
|
+
if (!st.isDirectory()) {
|
|
713
|
+
return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
|
|
714
|
+
}
|
|
715
|
+
} catch (e) {
|
|
716
|
+
return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Refuse duplicates: if any ccsm record already owns this upstream
|
|
720
|
+
// session id, return it so the caller can jump to it.
|
|
721
|
+
const all = await persistedSessions.loadAll();
|
|
722
|
+
const dup = all.find((s) => s.cliSessionId === cliSessionId);
|
|
723
|
+
if (dup) return res.json({ session: dup, alreadyAdopted: true });
|
|
724
|
+
|
|
725
|
+
const workspace = path.basename(resolvedCwd) || resolvedCwd;
|
|
726
|
+
// Create directly with status='exited' + cliSessionId set, so a
|
|
727
|
+
// concurrent GET /api/sessions can never observe a "running but no
|
|
728
|
+
// PTY" intermediate state.
|
|
729
|
+
const record = await persistedSessions.create({
|
|
730
|
+
cliId,
|
|
731
|
+
cwd: resolvedCwd,
|
|
732
|
+
workspace,
|
|
733
|
+
folderId: folderId || null,
|
|
734
|
+
title: title || '',
|
|
735
|
+
repos: [],
|
|
736
|
+
status: 'exited',
|
|
737
|
+
cliSessionId,
|
|
738
|
+
});
|
|
739
|
+
res.json({ session: record, alreadyAdopted: false });
|
|
740
|
+
}));
|
|
741
|
+
|
|
742
|
+
// ---- resume a previous session in the same cwd / cli ----
|
|
743
|
+
app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
744
|
+
const record = await persistedSessions.get(req.params.id);
|
|
745
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
746
|
+
// Already running and attached → no-op, just return its id.
|
|
747
|
+
const live = webTerminal.get(record.id);
|
|
748
|
+
if (live && !live.exitedAt) {
|
|
749
|
+
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
750
|
+
}
|
|
751
|
+
const cfg = await loadConfig();
|
|
752
|
+
const cli = pickCli(cfg, record.cliId);
|
|
753
|
+
if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
|
|
754
|
+
try {
|
|
755
|
+
// Prefer precise --resume <cliSessionId> when we have one captured;
|
|
756
|
+
// fall back to cli.resumeArgs (--continue / resume --last) otherwise.
|
|
757
|
+
const extraArgs = buildResumeArgs(cli, record);
|
|
758
|
+
const entry = spawnCliSession({
|
|
759
|
+
cli,
|
|
760
|
+
cwd: record.cwd,
|
|
761
|
+
sessionId: record.id,
|
|
762
|
+
meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
|
|
763
|
+
extraArgs,
|
|
764
|
+
});
|
|
765
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
766
|
+
res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
|
|
767
|
+
} catch (e) {
|
|
768
|
+
res.status(500).json({ error: e.message });
|
|
769
|
+
}
|
|
770
|
+
}));
|
|
771
|
+
|
|
772
|
+
// Build the args appended on resume:
|
|
773
|
+
// When ccsm has captured the upstream CLI's session UUID and the CLI
|
|
774
|
+
// defines `resumeIdArgs` (e.g. ['--resume', '<id>']), we substitute the
|
|
775
|
+
// <id> placeholder and use those for a precise resume. Otherwise we
|
|
776
|
+
// fall back to `cli.resumeArgs` (e.g. ['--continue']).
|
|
777
|
+
function buildResumeArgs(cli, record) {
|
|
778
|
+
const id = record.cliSessionId;
|
|
779
|
+
const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
|
|
780
|
+
if (id && tpl.length > 0) {
|
|
781
|
+
return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
|
|
782
|
+
}
|
|
783
|
+
return Array.isArray(cli.resumeArgs) ? cli.resumeArgs : [];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ---- capabilities probe ----
|
|
787
|
+
app.get('/api/capabilities', (_req, res) => res.json({
|
|
788
|
+
webTerminal: webTerminal.available,
|
|
789
|
+
webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
|
|
790
|
+
}));
|
|
791
|
+
|
|
792
|
+
// ---- health ----
|
|
793
|
+
const pkg = require('./package.json');
|
|
794
|
+
app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
|
|
795
|
+
|
|
796
|
+
// ---- lifecycle ----
|
|
797
|
+
let currentPort = 0;
|
|
798
|
+
let frontendUrl = '';
|
|
799
|
+
let lastHeartbeat = Date.now();
|
|
800
|
+
let heartbeatSeen = false;
|
|
801
|
+
const HEARTBEAT_TIMEOUT_MS = 90_000;
|
|
802
|
+
|
|
803
|
+
app.post('/api/heartbeat', (_req, res) => {
|
|
804
|
+
lastHeartbeat = Date.now();
|
|
805
|
+
heartbeatSeen = true;
|
|
806
|
+
res.json({ ok: true });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
app.post('/api/spawn-browser', asyncH(async (_req, res) => {
|
|
810
|
+
const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
|
|
811
|
+
res.json({ ok: true, mode: opened.kind, url: frontendUrl });
|
|
812
|
+
}));
|
|
813
|
+
|
|
814
|
+
app.post('/api/shutdown', (_req, res) => {
|
|
815
|
+
res.json({ ok: true, bye: 'shutting down' });
|
|
816
|
+
setImmediate(() => gracefulShutdown('/api/shutdown'));
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// ---- version / upgrade ----
|
|
820
|
+
// `/api/version` reports the installed version (= pkg.version) and, if
|
|
821
|
+
// reachable, the latest published on the npm registry. The result is
|
|
822
|
+
// cached for 30 minutes in memory so the AboutPage poll doesn't hit the
|
|
823
|
+
// registry on every render.
|
|
824
|
+
//
|
|
825
|
+
// `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
|
|
826
|
+
// detached child. When the install completes, the child re-spawns `ccsm`
|
|
827
|
+
// (also detached) so the launcher comes back up on the new version, and
|
|
828
|
+
// the current server gracefulShutdowns. The frontend's OfflineBanner
|
|
829
|
+
// covers the gap; the version router picks up the new version on the
|
|
830
|
+
// next probe.
|
|
831
|
+
const VERSION_CACHE_MS = 30 * 60_000;
|
|
832
|
+
let versionCache = null; // { latest, fetchedAt }
|
|
833
|
+
let upgradeInFlight = false;
|
|
834
|
+
|
|
835
|
+
async function fetchLatestFromNpm() {
|
|
836
|
+
// Node 18+ has a global fetch. Time out the registry call to avoid
|
|
837
|
+
// hanging the response when the user is offline / behind a captive
|
|
838
|
+
// portal.
|
|
839
|
+
const ctrl = new AbortController();
|
|
840
|
+
const t = setTimeout(() => ctrl.abort(), 4000);
|
|
841
|
+
try {
|
|
842
|
+
const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
|
|
843
|
+
headers: { 'Accept': 'application/json' },
|
|
844
|
+
signal: ctrl.signal,
|
|
845
|
+
});
|
|
846
|
+
if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
|
|
847
|
+
const j = await r.json();
|
|
848
|
+
return String(j.version || '');
|
|
849
|
+
} finally {
|
|
850
|
+
clearTimeout(t);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function cmpSemver(a, b) {
|
|
855
|
+
const pa = String(a || '').split('.').map(Number);
|
|
856
|
+
const pb = String(b || '').split('.').map(Number);
|
|
857
|
+
for (let i = 0; i < 3; i++) {
|
|
858
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
859
|
+
if (x > y) return 1;
|
|
860
|
+
if (x < y) return -1;
|
|
861
|
+
}
|
|
862
|
+
return 0;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
app.get('/api/version', asyncH(async (req, res) => {
|
|
866
|
+
const force = String(req.query.refresh || '') === '1';
|
|
867
|
+
const now = Date.now();
|
|
868
|
+
if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
|
|
869
|
+
return res.json({
|
|
870
|
+
current: pkg.version,
|
|
871
|
+
latest: versionCache.latest,
|
|
872
|
+
updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
|
|
873
|
+
fetchedAt: versionCache.fetchedAt,
|
|
874
|
+
cached: true,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
const latest = await fetchLatestFromNpm();
|
|
879
|
+
versionCache = { latest, fetchedAt: now };
|
|
880
|
+
res.json({
|
|
881
|
+
current: pkg.version,
|
|
882
|
+
latest,
|
|
883
|
+
updateAvailable: cmpSemver(latest, pkg.version) > 0,
|
|
884
|
+
fetchedAt: now,
|
|
885
|
+
cached: false,
|
|
886
|
+
});
|
|
887
|
+
} catch (e) {
|
|
888
|
+
// Swallow: surface "unknown" so the UI doesn't keep showing a stale
|
|
889
|
+
// "update available" badge based on a 6-hour-old cached value.
|
|
890
|
+
res.json({
|
|
891
|
+
current: pkg.version,
|
|
892
|
+
latest: null,
|
|
893
|
+
updateAvailable: false,
|
|
894
|
+
fetchedAt: now,
|
|
895
|
+
error: String(e.message || e),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}));
|
|
899
|
+
|
|
900
|
+
app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
901
|
+
if (upgradeInFlight) {
|
|
902
|
+
return res.status(409).json({ error: 'upgrade already in progress' });
|
|
903
|
+
}
|
|
904
|
+
upgradeInFlight = true;
|
|
905
|
+
const target = String((req.body && req.body.target) || 'latest');
|
|
906
|
+
// Refuse anything that doesn't look like a semver dist-tag or version
|
|
907
|
+
// — defends against `;` etc. winding up in the spawn argv even though
|
|
908
|
+
// we don't shell out.
|
|
909
|
+
if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
|
|
910
|
+
upgradeInFlight = false;
|
|
911
|
+
return res.status(400).json({ error: `invalid target: ${target}` });
|
|
912
|
+
}
|
|
913
|
+
console.log(`[upgrade] starting npm i -g @bakapiano/ccsm@${target}`);
|
|
914
|
+
res.json({ ok: true, started: true, target });
|
|
915
|
+
|
|
916
|
+
// Defer the actual spawn so the HTTP response flushes first.
|
|
917
|
+
setImmediate(() => {
|
|
918
|
+
const { spawn } = require('node:child_process');
|
|
919
|
+
const npmExe = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
920
|
+
const args = ['i', '-g', `@bakapiano/ccsm@${target}`];
|
|
921
|
+
const child = spawn(npmExe, args, {
|
|
922
|
+
detached: true,
|
|
923
|
+
stdio: 'ignore',
|
|
924
|
+
windowsHide: true,
|
|
925
|
+
shell: false,
|
|
926
|
+
});
|
|
927
|
+
child.on('error', (e) => {
|
|
928
|
+
console.error('[upgrade] npm spawn failed:', e.message);
|
|
929
|
+
upgradeInFlight = false;
|
|
930
|
+
});
|
|
931
|
+
child.on('exit', (code) => {
|
|
932
|
+
console.log(`[upgrade] npm exit ${code}`);
|
|
933
|
+
upgradeInFlight = false;
|
|
934
|
+
if (code !== 0) return;
|
|
935
|
+
// Install succeeded → spawn a fresh ccsm and shut down. The
|
|
936
|
+
// launcher already detaches on its own.
|
|
937
|
+
try {
|
|
938
|
+
const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
|
|
939
|
+
const respawn = spawn(ccsmCmd, [], {
|
|
940
|
+
detached: true,
|
|
941
|
+
stdio: 'ignore',
|
|
942
|
+
windowsHide: true,
|
|
943
|
+
shell: false,
|
|
944
|
+
env: { ...process.env, CCSM_NO_BROWSER: '1' },
|
|
945
|
+
});
|
|
946
|
+
respawn.unref();
|
|
947
|
+
} catch (e) {
|
|
948
|
+
console.error('[upgrade] respawn failed:', e.message);
|
|
949
|
+
}
|
|
950
|
+
setTimeout(() => gracefulShutdown('upgrade'), 1500);
|
|
951
|
+
});
|
|
952
|
+
child.unref();
|
|
953
|
+
});
|
|
954
|
+
}));
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
function listenWithFallback(preferred) {
|
|
958
|
+
return new Promise((resolve, reject) => {
|
|
959
|
+
const attempt = (port, tries) => {
|
|
960
|
+
const server = app.listen(port);
|
|
961
|
+
server.once('listening', () => resolve({ server, port: server.address().port }));
|
|
962
|
+
server.once('error', (err) => {
|
|
963
|
+
if (err.code !== 'EADDRINUSE') return reject(err);
|
|
964
|
+
if (tries < 9) attempt(port + 1, tries + 1);
|
|
965
|
+
else if (tries === 9) attempt(0, tries + 1);
|
|
966
|
+
else reject(err);
|
|
967
|
+
});
|
|
968
|
+
};
|
|
969
|
+
attempt(preferred, 0);
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function findAppModeBrowser() {
|
|
974
|
+
const candidates = [
|
|
975
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
976
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
977
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
978
|
+
process.env.LOCALAPPDATA &&
|
|
979
|
+
path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
|
|
980
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
981
|
+
].filter(Boolean);
|
|
982
|
+
const fs = require('node:fs');
|
|
983
|
+
for (const p of candidates) {
|
|
984
|
+
if (fs.existsSync(p)) return p;
|
|
985
|
+
}
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Auto-open the frontend in a browser when ccsm boots. Strategy: try a
|
|
990
|
+
// chromeless app window first (Edge/Chrome --app=); if neither is
|
|
991
|
+
// installed, fall back to the OS default browser as a regular tab. On
|
|
992
|
+
// non-Windows we skip — the bundled launcher isn't ported yet.
|
|
993
|
+
function openInBrowser(url) {
|
|
994
|
+
if (process.platform !== 'win32') return { kind: 'none', child: null };
|
|
995
|
+
const { spawn } = require('node:child_process');
|
|
996
|
+
const fs = require('node:fs');
|
|
997
|
+
const exe = findAppModeBrowser();
|
|
998
|
+
if (exe) {
|
|
999
|
+
const profileDir = path.join(DATA_DIR, 'browser-profile');
|
|
1000
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
1001
|
+
const child = spawn(
|
|
1002
|
+
exe,
|
|
1003
|
+
[
|
|
1004
|
+
`--app=${url}`,
|
|
1005
|
+
`--user-data-dir=${profileDir}`,
|
|
1006
|
+
'--window-size=1500,1100',
|
|
1007
|
+
'--no-first-run',
|
|
1008
|
+
'--no-default-browser-check',
|
|
1009
|
+
],
|
|
1010
|
+
{ detached: true, stdio: 'ignore' }
|
|
1011
|
+
);
|
|
1012
|
+
child.unref();
|
|
1013
|
+
return { kind: 'app', child };
|
|
1014
|
+
}
|
|
1015
|
+
console.log('[ccsm] no Edge/Chrome found, opening default browser');
|
|
1016
|
+
const child = spawn('cmd.exe', ['/c', 'start', '', url], {
|
|
1017
|
+
detached: true,
|
|
1018
|
+
stdio: 'ignore',
|
|
1019
|
+
windowsHide: true,
|
|
1020
|
+
});
|
|
1021
|
+
child.unref();
|
|
1022
|
+
return { kind: 'tab', child: null };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
(async () => {
|
|
1026
|
+
const cfg = await loadConfig();
|
|
1027
|
+
const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
|
|
1028
|
+
const { server, port } = await listenWithFallback(preferredPort);
|
|
1029
|
+
currentPort = port;
|
|
1030
|
+
|
|
1031
|
+
// On boot, mark any persisted "running" sessions as exited — they
|
|
1032
|
+
// belong to a previous server process whose PTYs are gone.
|
|
1033
|
+
try {
|
|
1034
|
+
const all = await persistedSessions.loadAll();
|
|
1035
|
+
for (const s of all) {
|
|
1036
|
+
if (s.status === 'running') {
|
|
1037
|
+
await persistedSessions.markExited(s.id, null);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
console.error('[ccsm] could not reconcile persisted sessions:', e.message);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Prewarm `tasklist` cache used by the import modal's "live" markers —
|
|
1045
|
+
// it takes ~500ms on Windows and is the single biggest contributor to
|
|
1046
|
+
// a slow Import dialog cold-open. Fire in the background; the lib also
|
|
1047
|
+
// starts its own 15s refresh loop.
|
|
1048
|
+
try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
|
|
1049
|
+
|
|
1050
|
+
if (webTerminal.available) {
|
|
1051
|
+
let WebSocketServer;
|
|
1052
|
+
try { ({ WebSocketServer } = require('ws')); } catch {}
|
|
1053
|
+
if (WebSocketServer) {
|
|
1054
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1055
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1056
|
+
const origin = req.headers.origin;
|
|
1057
|
+
if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
|
|
1058
|
+
socket.destroy();
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
|
|
1062
|
+
if (!m) { socket.destroy(); return; }
|
|
1063
|
+
const id = decodeURIComponent(m[1]);
|
|
1064
|
+
wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
|
|
1065
|
+
});
|
|
1066
|
+
console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
1071
|
+
process.on(sig, () => gracefulShutdown(sig));
|
|
1072
|
+
}
|
|
1073
|
+
process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
|
|
1074
|
+
|
|
1075
|
+
const apiUrl = `http://localhost:${port}`;
|
|
1076
|
+
const FRONTEND_URL = IS_DEV
|
|
1077
|
+
? apiUrl
|
|
1078
|
+
: 'https://bakapiano.github.io/ccsm/';
|
|
1079
|
+
frontendUrl = FRONTEND_URL;
|
|
1080
|
+
console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
|
|
1081
|
+
console.log(`frontend at ${FRONTEND_URL}`);
|
|
1082
|
+
console.log(`data dir: ${DATA_DIR}`);
|
|
1083
|
+
console.log(`work dir: ${cfg.workDir}`);
|
|
1084
|
+
console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
|
|
1085
|
+
|
|
1086
|
+
// CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
|
|
1087
|
+
// the auto-open entirely. Otherwise try app-mode (chromeless Edge/Chrome
|
|
1088
|
+
// window); if no such browser is installed, openInBrowser falls back to
|
|
1089
|
+
// the OS default browser on its own.
|
|
1090
|
+
const opened = process.env.CCSM_NO_BROWSER === '1'
|
|
1091
|
+
? { kind: 'none', child: null }
|
|
1092
|
+
: openInBrowser(FRONTEND_URL);
|
|
1093
|
+
|
|
1094
|
+
if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
1095
|
+
const launchedAt = Date.now();
|
|
1096
|
+
opened.child.on('exit', () => {
|
|
1097
|
+
const alive = Date.now() - launchedAt;
|
|
1098
|
+
if (alive < 5000) {
|
|
1099
|
+
console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const closedAt = Date.now();
|
|
1103
|
+
setTimeout(() => {
|
|
1104
|
+
if (lastHeartbeat > closedAt + 100) {
|
|
1105
|
+
console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
gracefulShutdown('browser window closed');
|
|
1109
|
+
}, 12_000);
|
|
1110
|
+
});
|
|
1111
|
+
console.log('[ccsm] tied to browser window — close it to stop ccsm');
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
1115
|
+
setInterval(() => {
|
|
1116
|
+
if (!heartbeatSeen) return;
|
|
1117
|
+
if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
|
|
1118
|
+
gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
|
|
1119
|
+
}
|
|
1120
|
+
}, 30_000);
|
|
1121
|
+
console.log('[ccsm] heartbeat watchdog active');
|
|
1122
|
+
}
|
|
1123
|
+
})().catch((err) => {
|
|
1124
|
+
console.error('startup failed:', err);
|
|
1125
|
+
process.exit(1);
|
|
1126
|
+
});
|