dsh-code 0.9.1 → 1.0.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/README.en.md +29 -13
- package/README.md +264 -248
- package/bin/deepseek.mjs +100 -6
- package/cordis.patch.yml +29 -1
- package/lib/index.mjs +2223 -687
- package/lib/startup.mjs +21 -11
- package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
- package/lib/types/app.d.ts +66 -14
- package/lib/types/attachments.d.ts +7 -0
- package/lib/types/editor.d.ts +6 -0
- package/lib/types/fork.d.ts +8 -0
- package/lib/types/git-workflow.d.ts +23 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/kernel-panels.d.ts +39 -0
- package/lib/types/keyboard.d.ts +43 -0
- package/lib/types/mentions.d.ts +28 -38
- package/lib/types/presets.d.ts +1 -3
- package/lib/types/provider-settings.d.ts +16 -0
- package/lib/types/render/animations.d.ts +10 -39
- package/lib/types/render/editor.d.ts +137 -0
- package/lib/types/render/export.d.ts +1 -1
- package/lib/types/render/lines.d.ts +6 -2
- package/lib/types/render/markdown.d.ts +3 -1
- package/lib/types/render/projection.d.ts +29 -3
- package/lib/types/render/status.d.ts +5 -12
- package/lib/types/session-directory.d.ts +1 -3
- package/lib/types/startup.d.ts +14 -11
- package/lib/types/store.d.ts +11 -9
- package/lib/types/subagents.d.ts +3 -3
- package/lib/types/theme.d.ts +14 -1
- package/lib/types/version.d.ts +15 -2
- package/package.json +153 -141
- package/src/app.ts +4455 -3917
- package/src/attachments.ts +44 -0
- package/src/editor.ts +51 -0
- package/src/fork.ts +31 -0
- package/src/git-workflow.ts +87 -0
- package/src/index.ts +1510 -1374
- package/src/internals.ts +14 -1
- package/src/kernel-panels.ts +914 -798
- package/src/keyboard.ts +125 -0
- package/src/mentions.ts +72 -117
- package/src/presets.ts +1 -4
- package/src/provider-settings.ts +94 -0
- package/src/render/animations.ts +25 -55
- package/src/render/editor.ts +398 -0
- package/src/render/export.ts +79 -79
- package/src/render/lines.ts +342 -236
- package/src/render/markdown.ts +99 -26
- package/src/render/projection.ts +102 -19
- package/src/render/status.ts +713 -650
- package/src/render/text.ts +150 -150
- package/src/render/tool-detail.ts +3 -1
- package/src/session-directory.ts +3 -3
- package/src/startup.ts +136 -119
- package/src/store.ts +23 -11
- package/src/subagents.ts +13 -5
- package/src/theme.ts +214 -206
- package/src/version.ts +58 -1
package/src/index.ts
CHANGED
|
@@ -1,1374 +1,1510 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
3
|
-
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
4
|
-
* creates or resumes preset-composed Agents through the core registry, keeps
|
|
5
|
-
* one Ink owner while the active session changes, folds submitted prompts
|
|
6
|
-
* into the selected durable session, answers approval asks with a y/n bar,
|
|
7
|
-
* dispatches slash commands, and on quit flushes and requests process exit.
|
|
8
|
-
*
|
|
9
|
-
* @module @deepseek-ai/dsh-code
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { randomUUID } from 'node:crypto'
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
14
|
-
import { homedir } from 'node:os'
|
|
15
|
-
import { mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
|
-
import { basename, dirname, join } from 'node:path'
|
|
17
|
-
import { createElement } from 'react'
|
|
18
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
-
import z from '@deepseek-ai/schemastery'
|
|
20
|
-
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
-
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
|
-
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import type
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
import type {} from '@deepseek-ai/
|
|
32
|
-
import
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
import
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
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
|
-
export
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
*
|
|
164
|
-
* @param
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
bridge.notify(`session
|
|
791
|
-
})
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
if (
|
|
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
|
-
if (
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
3
|
+
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
4
|
+
* creates or resumes preset-composed Agents through the core registry, keeps
|
|
5
|
+
* one Ink owner while the active session changes, folds submitted prompts
|
|
6
|
+
* into the selected durable session, answers approval asks with a y/n bar,
|
|
7
|
+
* dispatches slash commands, and on quit flushes and requests process exit.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
|
+
import { basename, dirname, join } from 'node:path'
|
|
17
|
+
import { createElement } from 'react'
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import z from '@deepseek-ai/schemastery'
|
|
20
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
+
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-attachment'
|
|
24
|
+
import { createUserMessage, MessageId, type ContentBlock, type ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
25
|
+
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
26
|
+
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
27
|
+
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
28
|
+
import type {} from '@deepseek-ai/dsh-session-title'
|
|
29
|
+
// Empty type imports carry the loader Context merge for the settlement await
|
|
30
|
+
// and the cmdline Context merge for the appExit host value.
|
|
31
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
32
|
+
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
33
|
+
import { App, type NoticeTone } from './app.ts'
|
|
34
|
+
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
35
|
+
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
36
|
+
import { internals, type TuiMount } from './internals.ts'
|
|
37
|
+
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
38
|
+
import {
|
|
39
|
+
loadProviderSettings,
|
|
40
|
+
removeProviderSettings,
|
|
41
|
+
saveProviderCredential,
|
|
42
|
+
saveProviderConfiguration,
|
|
43
|
+
subscribeProviderSettings,
|
|
44
|
+
unsetProviderCredential,
|
|
45
|
+
} from './provider-settings.ts'
|
|
46
|
+
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
47
|
+
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
48
|
+
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
49
|
+
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
50
|
+
import { parseStatuslineItems } from './render/status.ts'
|
|
51
|
+
import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
52
|
+
import { watchSkills, type SkillsView } from './skills.ts'
|
|
53
|
+
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
54
|
+
import { buildExportMarkdown } from './render/export.ts'
|
|
55
|
+
import { saveImagePaths } from './attachments.ts'
|
|
56
|
+
import { copyText, latestAssistantText } from './editor.ts'
|
|
57
|
+
import { selectForkSeed } from './fork.ts'
|
|
58
|
+
import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
|
|
59
|
+
import type { TuiStartup } from './startup.ts'
|
|
60
|
+
import { SessionSwitchQueue } from './session-switch.ts'
|
|
61
|
+
import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
|
|
62
|
+
import {
|
|
63
|
+
applyPendingPermission,
|
|
64
|
+
cyclePermission as cyclePermissionPreset,
|
|
65
|
+
effectivePermission,
|
|
66
|
+
listPermissionRows,
|
|
67
|
+
permissionPresetsFrom,
|
|
68
|
+
selectPermission,
|
|
69
|
+
} from './permissions.ts'
|
|
70
|
+
import { listPluginRows } from './plugin-inventory.ts'
|
|
71
|
+
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
72
|
+
import {
|
|
73
|
+
collectDeletionSubtree,
|
|
74
|
+
isSubagentSession,
|
|
75
|
+
matchSessionId,
|
|
76
|
+
mergeSessionTitles,
|
|
77
|
+
newestRootForCwd,
|
|
78
|
+
projectSessionRows,
|
|
79
|
+
SESSION_ARTIFACT_NAMES,
|
|
80
|
+
sessionArtifactDirectory,
|
|
81
|
+
type SessionDirectoryOptions,
|
|
82
|
+
type SessionQueryService,
|
|
83
|
+
type SessionRow,
|
|
84
|
+
} from './session-directory.ts'
|
|
85
|
+
|
|
86
|
+
/** Stable Cordis plugin name. */
|
|
87
|
+
export const name = 'tui-runner'
|
|
88
|
+
|
|
89
|
+
/** Core services required before the interactive session can start. */
|
|
90
|
+
export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
|
91
|
+
|
|
92
|
+
/** Plugin config: the startup resolved from this app's injected provider service. */
|
|
93
|
+
export interface Config {
|
|
94
|
+
/** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
|
|
95
|
+
startup: { kind: string; sessionId?: string; mode?: string; theme?: string; prompt?: string; images?: string[] }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const Config: z<Config> = z.object({
|
|
99
|
+
startup: z.object({
|
|
100
|
+
kind: z.string().required(),
|
|
101
|
+
sessionId: z.string(),
|
|
102
|
+
mode: z.string(),
|
|
103
|
+
theme: z.string(),
|
|
104
|
+
prompt: z.string(),
|
|
105
|
+
images: z.array(z.string()),
|
|
106
|
+
}),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
|
|
110
|
+
interface TuiIo {
|
|
111
|
+
mount: typeof internals.mount
|
|
112
|
+
exit(code: number): void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
116
|
+
function fail(io: TuiIo, error: unknown): void {
|
|
117
|
+
internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
118
|
+
io.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The `ctx.jobs` registry face (dsh-jobs-local behind the base patch row). */
|
|
122
|
+
interface JobsServiceLike {
|
|
123
|
+
list(caller?: Agent): ReadonlyArray<{
|
|
124
|
+
id: string
|
|
125
|
+
kind: string
|
|
126
|
+
label: string
|
|
127
|
+
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
|
128
|
+
detail?: string
|
|
129
|
+
startedAt: number
|
|
130
|
+
finishedAt?: number
|
|
131
|
+
}>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Snapshot caller-visible background jobs for the /jobs panel. Jobs the agent
|
|
136
|
+
* started through run_in_background are fenced by their owner, so the CURRENT
|
|
137
|
+
* agent is the caller. A missing registry is a harmless absence (the base
|
|
138
|
+
* composition may not mount one) and collapses to the empty panel state —
|
|
139
|
+
* the documented degradation for harmless probes, not an error.
|
|
140
|
+
* @param ctx - context carrying the optional `jobs` registry.
|
|
141
|
+
* @param caller - the active agent (undefined sees only unowned jobs).
|
|
142
|
+
* @returns job rows in registration order; never throws.
|
|
143
|
+
*/
|
|
144
|
+
function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./kernel-panels.ts').JobRow[] {
|
|
145
|
+
const jobs = (ctx as unknown as { get(name: string): unknown }).get('jobs') as JobsServiceLike | undefined
|
|
146
|
+
if (jobs === undefined) return []
|
|
147
|
+
try {
|
|
148
|
+
return jobs.list(caller).map(job => ({
|
|
149
|
+
id: job.id,
|
|
150
|
+
kind: job.kind,
|
|
151
|
+
label: job.label,
|
|
152
|
+
status: job.status,
|
|
153
|
+
detail: job.detail,
|
|
154
|
+
startedAt: job.startedAt,
|
|
155
|
+
finishedAt: job.finishedAt,
|
|
156
|
+
}))
|
|
157
|
+
} catch {
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the working directory's git branch for the status line.
|
|
164
|
+
* @param cwd - the session's working directory.
|
|
165
|
+
* @returns the branch name, or '' outside a repository or on a detached HEAD.
|
|
166
|
+
*/
|
|
167
|
+
function gitBranch(cwd: string): string {
|
|
168
|
+
try {
|
|
169
|
+
const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
|
|
170
|
+
return ref?.[1] ?? ''
|
|
171
|
+
} catch {
|
|
172
|
+
// Only the single HEAD read is attempted, so the sole reachable failure is
|
|
173
|
+
// a missing repository (or unreadable HEAD file): the branch group drops out.
|
|
174
|
+
return ''
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The session identity this invocation will run, plus whether it is resumed. */
|
|
179
|
+
interface Target {
|
|
180
|
+
sessionId: string
|
|
181
|
+
resume: boolean
|
|
182
|
+
mode?: string
|
|
183
|
+
cwd?: string
|
|
184
|
+
seed?: readonly SessionEvent[]
|
|
185
|
+
parentSession?: SessionId
|
|
186
|
+
seedLength?: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
191
|
+
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
192
|
+
* user text: path separators must never leak into the default export filename
|
|
193
|
+
* (which would escape the session cwd).
|
|
194
|
+
* @param id - the session id.
|
|
195
|
+
* @returns at most the last 8 filename-safe characters.
|
|
196
|
+
*/
|
|
197
|
+
export function exportSessionIdSuffix(id: string): string {
|
|
198
|
+
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One ordered step of the terminal quit cleanup. */
|
|
202
|
+
export interface QuitCleanupStep {
|
|
203
|
+
/** Step label used in diagnostics and tests. */
|
|
204
|
+
readonly name: string
|
|
205
|
+
/** The step's async work; a rejection is contained by the sequence. */
|
|
206
|
+
readonly run: () => Promise<void>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Run the ordered quit cleanup, then request exit. Every step rejection is
|
|
211
|
+
* contained (reported through `onError`) so a failed flush or dispose never
|
|
212
|
+
* skips the remaining cleanup; the exit request is always reached exactly
|
|
213
|
+
* once.
|
|
214
|
+
* @param steps - the cleanup steps in dependency order (settle the visible
|
|
215
|
+
* session, await the final in-flight composition, await durable recall).
|
|
216
|
+
* @param exit - the terminal exit request (code 0).
|
|
217
|
+
* @param onError - optional failure sink; called once per failing step and
|
|
218
|
+
* itself contained, so a throwing sink cannot abort the sequence.
|
|
219
|
+
* @returns the names of the steps that started, in order (for tests).
|
|
220
|
+
*/
|
|
221
|
+
export async function runQuitSequence(
|
|
222
|
+
steps: readonly QuitCleanupStep[],
|
|
223
|
+
exit: (code: number) => void,
|
|
224
|
+
onError?: (name: string, error: unknown) => void,
|
|
225
|
+
): Promise<readonly string[]> {
|
|
226
|
+
const started: string[] = []
|
|
227
|
+
for (const step of steps) {
|
|
228
|
+
started.push(step.name)
|
|
229
|
+
try {
|
|
230
|
+
await step.run()
|
|
231
|
+
} catch (error) {
|
|
232
|
+
try {
|
|
233
|
+
onError?.(step.name, error)
|
|
234
|
+
} catch {
|
|
235
|
+
// The failure sink must never abort the cleanup sequence.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
exit(0)
|
|
241
|
+
} catch {
|
|
242
|
+
// The exit request itself must not become an unhandled rejection.
|
|
243
|
+
}
|
|
244
|
+
return started
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolve the invocation's target session against the persisted headers.
|
|
249
|
+
* @param startup - the parsed startup flags.
|
|
250
|
+
* @param persistence - the persistence service; required for resume/latest.
|
|
251
|
+
* @param cwd - the working directory `--continue` filters by.
|
|
252
|
+
* @returns the target identity.
|
|
253
|
+
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
254
|
+
*/
|
|
255
|
+
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
256
|
+
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
257
|
+
if (startup.kind === 'named') {
|
|
258
|
+
// The id must not exist yet: reject before any Agent composition when the
|
|
259
|
+
// backend can tell us (a live collision is still caught by the session
|
|
260
|
+
// store at create time).
|
|
261
|
+
if (persistence !== undefined) {
|
|
262
|
+
const headers: readonly SessionHeader[] = await persistence.list()
|
|
263
|
+
if (headers.some(header => header.id === startup.sessionId)) {
|
|
264
|
+
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
268
|
+
}
|
|
269
|
+
if (persistence === undefined) {
|
|
270
|
+
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
271
|
+
}
|
|
272
|
+
const headers: readonly SessionHeader[] = await persistence.list()
|
|
273
|
+
if (startup.kind === 'resume') {
|
|
274
|
+
const matched = matchSessionId(headers, startup.sessionId)
|
|
275
|
+
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
276
|
+
// be a back door into appending root turns to a child's durable log.
|
|
277
|
+
if (isSubagentSession(matched)) {
|
|
278
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
279
|
+
}
|
|
280
|
+
return { sessionId: matched.id, resume: true }
|
|
281
|
+
}
|
|
282
|
+
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
283
|
+
const newest = newestRootForCwd(headers, cwd)
|
|
284
|
+
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
285
|
+
return { sessionId: newest.id, resume: true }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Resolve a bounded command preview for one pending approval: the request
|
|
290
|
+
* contract carries no arguments, so the bar self-serves from the transcript
|
|
291
|
+
* projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
|
|
292
|
+
* @param events - the transcript entries to search.
|
|
293
|
+
* @param callId - the tool call the question is about, when the asker had one.
|
|
294
|
+
* @param toolName - the tool the question is about.
|
|
295
|
+
* @returns a bounded preview line, '' when nothing useful resolves.
|
|
296
|
+
*/
|
|
297
|
+
function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
|
|
298
|
+
if (callId === undefined) return ''
|
|
299
|
+
const entry = events.find(candidate =>
|
|
300
|
+
candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
|
|
301
|
+
if (entry === undefined) return ''
|
|
302
|
+
const args = (entry as { arguments?: string }).arguments ?? ''
|
|
303
|
+
return toolArgumentsPreview(args, toolName)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The runner's connection between the React app and the process side. */
|
|
307
|
+
interface AppBridge {
|
|
308
|
+
/** Post one local notice line (feedback the transcript does not carry). */
|
|
309
|
+
notify(text: string, tone?: NoticeTone): void
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Run the interactive terminal session: resolve the target session, create or
|
|
314
|
+
* resume one Agent, mount the app, and keep the process alive until the user
|
|
315
|
+
* quits.
|
|
316
|
+
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
317
|
+
* @param startup - the parsed invocation flags.
|
|
318
|
+
* @param io - process-facing effects.
|
|
319
|
+
*/
|
|
320
|
+
async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
|
|
321
|
+
// Loader siblings mount concurrently. Await the complete application before
|
|
322
|
+
// creating an Agent so its scoped tools and adapters are not half-composed.
|
|
323
|
+
await ctx.get('loader')?.await()
|
|
324
|
+
const agents = ctx.get('agents')
|
|
325
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
326
|
+
const sessions = ctx.get('sessions')
|
|
327
|
+
const persistence = ctx.get('sessionPersistence')
|
|
328
|
+
const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
|
|
329
|
+
// Early process shutdown can dispose the tree while settlement is pending.
|
|
330
|
+
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
331
|
+
|
|
332
|
+
const cwd = process.cwd()
|
|
333
|
+
// Live deployment default (web selectModel parity): read on every use, not
|
|
334
|
+
// snapshotted at launch, so a /model pick this process saves becomes the
|
|
335
|
+
// default for sessions composed afterwards without a restart.
|
|
336
|
+
const currentDefaults = (): ModelSelection => defaultModel.currentSelection()
|
|
337
|
+
const presets = agentPresetsFrom(ctx)
|
|
338
|
+
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
339
|
+
const permissionPresets = permissionPresetsFrom(ctx)
|
|
340
|
+
|
|
341
|
+
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
342
|
+
// nothing is persisted, until the user's first real input. Explicit flags
|
|
343
|
+
// (--resume/--continue/--session/--mode) keep the eager create/resume path.
|
|
344
|
+
const lazy = startup.kind === 'fresh' && startup.mode === undefined
|
|
345
|
+
|
|
346
|
+
interface ActiveSession {
|
|
347
|
+
handle: AgentHandle
|
|
348
|
+
agent: Agent
|
|
349
|
+
session: Session
|
|
350
|
+
store: ReturnType<typeof createTranscriptStore>
|
|
351
|
+
mentions: MentionsApi
|
|
352
|
+
mode: string
|
|
353
|
+
selection: { picked?: ModelSelection }
|
|
354
|
+
resumed: boolean
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
358
|
+
const prepare = async (next: Target): Promise<ActiveSession> => {
|
|
359
|
+
const nextCwd = next.cwd ?? cwd
|
|
360
|
+
// A bare launch can pick a model before any session exists: the process
|
|
361
|
+
// keeps that explicit choice and every prepared session starts from it
|
|
362
|
+
// (the documented precedence: explicit pick > session header > default).
|
|
363
|
+
const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
|
|
364
|
+
? {}
|
|
365
|
+
: { picked: pendingSelection }
|
|
366
|
+
let mode = next.resume ? next.mode : next.mode ?? pendingMode
|
|
367
|
+
if (!next.resume) mode = (await presets.resolve(mode)).id
|
|
368
|
+
const setup = async (agentCtx: Context): Promise<void> => {
|
|
369
|
+
const sessionPreset = next.resume
|
|
370
|
+
? resolvePreset(agentCtx.agent!.session)
|
|
371
|
+
: mode
|
|
372
|
+
const mounted = await presets.mount(agentCtx, sessionPreset)
|
|
373
|
+
mode = mounted.id
|
|
374
|
+
const selection: ModelSelectionRef = {
|
|
375
|
+
get current(): ModelSelection | undefined {
|
|
376
|
+
return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, currentDefaults())
|
|
377
|
+
},
|
|
378
|
+
set current(value: ModelSelection | undefined) { selectionState.picked = value },
|
|
379
|
+
assembled: undefined,
|
|
380
|
+
}
|
|
381
|
+
installModelSelection(agentCtx, selection)
|
|
382
|
+
}
|
|
383
|
+
// AgentOptions seed the loop's fallback route; effort rides the selection
|
|
384
|
+
// ref (installModelSelection), so only the provider/model pair is seeded.
|
|
385
|
+
const seedOptions = pendingSelection === undefined
|
|
386
|
+
? { provider: currentDefaults().provider, model: currentDefaults().model }
|
|
387
|
+
: { provider: pendingSelection.provider, model: pendingSelection.model }
|
|
388
|
+
const handle = next.resume
|
|
389
|
+
? await agents.resume({
|
|
390
|
+
resumeSessionId: SessionId(next.sessionId),
|
|
391
|
+
agentOptions: seedOptions,
|
|
392
|
+
// Quit aborts an in-flight composition so the exit wait never hangs
|
|
393
|
+
// on a prepare that cannot settle; upstream rolls the creation back.
|
|
394
|
+
signal: quitAbort.signal,
|
|
395
|
+
setup,
|
|
396
|
+
})
|
|
397
|
+
: await agents.create({
|
|
398
|
+
sessionId: SessionId(next.sessionId),
|
|
399
|
+
meta: {
|
|
400
|
+
cwd: nextCwd,
|
|
401
|
+
agentPreset: mode,
|
|
402
|
+
...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
|
|
403
|
+
...(next.seedLength === undefined ? {} : { seedLength: next.seedLength }),
|
|
404
|
+
},
|
|
405
|
+
...(next.seed === undefined ? {} : { seed: next.seed }),
|
|
406
|
+
agentOptions: seedOptions,
|
|
407
|
+
signal: quitAbort.signal,
|
|
408
|
+
setup,
|
|
409
|
+
})
|
|
410
|
+
const session = handle.agent.session
|
|
411
|
+
if (!next.resume && permissionPresets !== undefined) {
|
|
412
|
+
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
handle,
|
|
416
|
+
agent: handle.agent,
|
|
417
|
+
session,
|
|
418
|
+
store: createTranscriptStore(session.events),
|
|
419
|
+
mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
|
|
420
|
+
mode: mode ?? 'standard',
|
|
421
|
+
selection: selectionState,
|
|
422
|
+
resumed: next.resume,
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let active: ActiveSession | undefined
|
|
427
|
+
let agent: Agent | undefined
|
|
428
|
+
let session: Session | undefined
|
|
429
|
+
let store: TranscriptStore = createTranscriptStore()
|
|
430
|
+
// Live subagent activity (child sessions of the current root): one bounded
|
|
431
|
+
// row per child, folded from the same event bus the transcript feeds on.
|
|
432
|
+
const subagents: SubagentFeedView & { apply(sessionId: string, event: SessionEvent): void; reset(): void } = createSubagentFeed()
|
|
433
|
+
// Pre-session @file completion runs the official search over the launch
|
|
434
|
+
// cwd (model- and session-independent); the prepare/activate paths replace
|
|
435
|
+
// this with the agent-scoped instance once a session exists.
|
|
436
|
+
let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
|
|
437
|
+
/** Explicit model pick made before any session exists (a bare launch). */
|
|
438
|
+
let pendingSelection: ModelSelection | undefined
|
|
439
|
+
/** Agent preset selected before the first session exists. */
|
|
440
|
+
let pendingMode: string | undefined
|
|
441
|
+
/** Ordered pre-session preset resolutions; first composition awaits them. */
|
|
442
|
+
let pendingModeWork: Promise<void> = Promise.resolve()
|
|
443
|
+
/** Permission preset selected before the first session exists. */
|
|
444
|
+
let pendingPermission: string | undefined
|
|
445
|
+
/**
|
|
446
|
+
* Monotonic session epoch: bumped on every successful activation, on every
|
|
447
|
+
* first-session creation, and on quit. Async callbacks (mention prepares,
|
|
448
|
+
* command executions) capture it at call time and drop their result when it
|
|
449
|
+
* changed, so a stale callback can never deliver to an agent that is no
|
|
450
|
+
* longer on screen.
|
|
451
|
+
*/
|
|
452
|
+
let epoch = 0
|
|
453
|
+
/** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
|
|
454
|
+
const quitAbort = new AbortController()
|
|
455
|
+
/** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
|
|
456
|
+
const pendingControllers = new Set<AbortController>()
|
|
457
|
+
const abortPendingControllers = (): void => {
|
|
458
|
+
for (const controller of [...pendingControllers]) {
|
|
459
|
+
pendingControllers.delete(controller)
|
|
460
|
+
controller.abort()
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** The in-flight session-composition turn (create/resume/activate), if any. */
|
|
464
|
+
let composing: Promise<void> | undefined
|
|
465
|
+
/**
|
|
466
|
+
* Run one session composition exclusively: concurrent compositions wait
|
|
467
|
+
* their turn, so a bare-launch first-session creation and a /resume
|
|
468
|
+
* activation can never compose agents in parallel (the loser would leak its
|
|
469
|
+
* agent or mis-deliver). Errors propagate to the caller; the shared slot
|
|
470
|
+
* always continues.
|
|
471
|
+
*/
|
|
472
|
+
const compose = (work: () => Promise<void>): Promise<void> => {
|
|
473
|
+
const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
|
|
474
|
+
composing = turn.catch(() => {})
|
|
475
|
+
return turn
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!lazy) {
|
|
479
|
+
const target = await resolveTarget(startup, persistence, cwd)
|
|
480
|
+
const prepared = await prepare(target)
|
|
481
|
+
active = prepared
|
|
482
|
+
agent = prepared.agent
|
|
483
|
+
session = prepared.session
|
|
484
|
+
store = prepared.store
|
|
485
|
+
mentions = prepared.mentions
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Seed the transcript from the full session log: constructor seeds never
|
|
489
|
+
// fire on `session/event`, so a resumed session paints its history once
|
|
490
|
+
// before the first render. The handler reads the current session/store, so
|
|
491
|
+
// the deferred first session of a bare launch is covered by the same feed.
|
|
492
|
+
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
493
|
+
if (session === undefined) return
|
|
494
|
+
if (subject.id === session.id) {
|
|
495
|
+
store.apply(event)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
// Child sessions (subagent conversations this root spawned) fold into
|
|
499
|
+
// the bounded live-activity feed, never the transcript: the root stays
|
|
500
|
+
// the only durable transcript truth while a running subagent remains
|
|
501
|
+
// visible. Lineage comes from the child header, same field the session
|
|
502
|
+
// directory uses to tag `↳` rows.
|
|
503
|
+
if (subject.header.parentSession === session.id && subject.header.origin === 'subagent') subagents.apply(subject.id, event)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const commands: CommandsView = watchCommands(ctx)
|
|
507
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
508
|
+
|
|
509
|
+
const skills: SkillsView = watchSkills(ctx)
|
|
510
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
511
|
+
|
|
512
|
+
// Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
|
|
513
|
+
// claimed, every other ask falls through to the fail-closed waterfall. The
|
|
514
|
+
// owner predicate is empty until the first session exists.
|
|
515
|
+
const approval: ApprovalStore = mountApprovalAnswerer(
|
|
516
|
+
ctx,
|
|
517
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
518
|
+
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
// Subagent model routing. The kernel seeds child agents from the parent's
|
|
522
|
+
// CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
|
|
523
|
+
// /model switch never touches — delegated work would keep running on the
|
|
524
|
+
// launch-time route. This plugin-level listener mirrors installModelSelection
|
|
525
|
+
// for subagent-origin requests (scope filtering delivers the agent subject
|
|
526
|
+
// inside the payload): the explicit /subagent override wins, else the root's
|
|
527
|
+
// effective selection (explicit pick > session header > deployment default).
|
|
528
|
+
// Effort rides the selection exactly like the kernel listener applies it.
|
|
529
|
+
let subagentOverride: ModelSelection | undefined
|
|
530
|
+
ctx.on('agent/request', (payload, next) => {
|
|
531
|
+
const subject = payload.agent
|
|
532
|
+
const header = subject.session.header
|
|
533
|
+
if (header.parentSession === undefined && header.origin !== 'subagent') return next()
|
|
534
|
+
const picked = subagentOverride
|
|
535
|
+
?? resolveEffectiveSelection(
|
|
536
|
+
active?.selection.picked ?? pendingSelection,
|
|
537
|
+
subject.session.requestHeader()?.config,
|
|
538
|
+
currentDefaults(),
|
|
539
|
+
)
|
|
540
|
+
return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
// ask_user_question provider: the single UI provider on the shared service,
|
|
544
|
+
// one request on screen at a time. Plan reviews (exit_plan_mode) arrive
|
|
545
|
+
// through this same pipe.
|
|
546
|
+
const questions: QuestionStore = mountQuestionProvider(ctx)
|
|
547
|
+
|
|
548
|
+
// The bridge the React app registers on mount: local notices from the
|
|
549
|
+
// process side (unknown commands, switch confirmations, cancels).
|
|
550
|
+
const bridge: AppBridge = { notify: () => {} }
|
|
551
|
+
|
|
552
|
+
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
553
|
+
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
554
|
+
// surfaced warning (the customization is user-authored, never silent).
|
|
555
|
+
const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
|
|
556
|
+
let statuslineWarning: string | undefined
|
|
557
|
+
let statuslineItems: readonly string[] = []
|
|
558
|
+
try {
|
|
559
|
+
statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
|
|
560
|
+
} catch (error) {
|
|
561
|
+
statuslineItems = parseStatuslineItems(undefined)
|
|
562
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
563
|
+
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const saveStatusline = (items: readonly string[]): void => {
|
|
567
|
+
statuslineItems = [...items]
|
|
568
|
+
// The config directory may not exist on a first save; create it before
|
|
569
|
+
// the write so a fresh install persists customizations.
|
|
570
|
+
void mkdir(dirname(statuslinePath), { recursive: true })
|
|
571
|
+
.then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
|
|
572
|
+
.catch((writeError: unknown) => {
|
|
573
|
+
bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// /theme persistence: one user-level JSON file under the DSH home, mirroring
|
|
578
|
+
// the statusline file. A missing file means the dark default; a corrupt file
|
|
579
|
+
// degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
|
|
580
|
+
// auto detection > dark (auto detection itself is a later enhancement and
|
|
581
|
+
// currently falls back to dark inside theme.ts).
|
|
582
|
+
const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
|
|
583
|
+
let themeWarning: string | undefined
|
|
584
|
+
if (startup.theme === undefined) {
|
|
585
|
+
try {
|
|
586
|
+
setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
589
|
+
themeWarning = error instanceof Error ? error.message : String(error)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
setTheme(startup.theme)
|
|
594
|
+
}
|
|
595
|
+
const saveTheme = (name: ThemeName): void => {
|
|
596
|
+
setTheme(name)
|
|
597
|
+
void mkdir(dirname(themePath), { recursive: true })
|
|
598
|
+
.then(() => writeFileAsync(themePath, JSON.stringify({ theme: name }, null, 2) + '\n', 'utf8'))
|
|
599
|
+
.catch((writeError: unknown) => {
|
|
600
|
+
bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Global input recall (Codex composer-history contract): one JSONL file
|
|
605
|
+
// under the DSH home. A missing file means an empty history; unreadable or
|
|
606
|
+
// corrupt content degrades to the valid lines it could parse, silently —
|
|
607
|
+
// recall is a convenience surface, never a gate.
|
|
608
|
+
const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
|
|
609
|
+
let inputHistory: readonly string[] = []
|
|
610
|
+
try {
|
|
611
|
+
inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
|
|
612
|
+
} catch {
|
|
613
|
+
inputHistory = []
|
|
614
|
+
}
|
|
615
|
+
/** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
|
|
616
|
+
let historyWriteChain: Promise<void> = Promise.resolve()
|
|
617
|
+
const recordHistory = (text: string): void => {
|
|
618
|
+
if (text === '') return
|
|
619
|
+
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
620
|
+
// Write the whole current list, serialized per submission: the file is
|
|
621
|
+
// never read back on the submit path, so rapid same-process submissions
|
|
622
|
+
// cannot lose entries to a read-modify-write race.
|
|
623
|
+
historyWriteChain = historyWriteChain
|
|
624
|
+
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
625
|
+
.then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
|
|
626
|
+
.catch((writeError: unknown) => {
|
|
627
|
+
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
|
|
632
|
+
const cancelQueued = (messageId: string): void => {
|
|
633
|
+
if (agent === undefined) return
|
|
634
|
+
try {
|
|
635
|
+
if (agent.inbox.remove(MessageId(messageId))) {
|
|
636
|
+
bridge.notify('queued message cancelled')
|
|
637
|
+
}
|
|
638
|
+
} catch (error: unknown) {
|
|
639
|
+
bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// The mount handle lives in a box: quit closes over it, while the mount
|
|
644
|
+
// itself is created after quit (the App element needs quit as a prop).
|
|
645
|
+
const mountRef: { current?: TuiMount } = {}
|
|
646
|
+
let quitting = false
|
|
647
|
+
const quit = (): void => {
|
|
648
|
+
if (quitting) return
|
|
649
|
+
quitting = true
|
|
650
|
+
switchQueue.cancel()
|
|
651
|
+
// Stale prepares/commands die with the session they were for. Aborting
|
|
652
|
+
// the composition signal lets a never-settling prepare reject, so the
|
|
653
|
+
// exit wait below cannot hang (upstream rolls the creation back).
|
|
654
|
+
abortPendingControllers()
|
|
655
|
+
quitAbort.abort()
|
|
656
|
+
epoch += 1
|
|
657
|
+
off()
|
|
658
|
+
mountRef.current?.unmount()
|
|
659
|
+
const currentSession = session
|
|
660
|
+
const currentActive = active
|
|
661
|
+
const report = (name: string, error: unknown): void => {
|
|
662
|
+
internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
663
|
+
}
|
|
664
|
+
// One ordered cleanup: settle the visible session (if any — a bare launch
|
|
665
|
+
// that never composed one resolves immediately), then wait for the final
|
|
666
|
+
// in-flight composition (its work swallows errors and the quitting guard
|
|
667
|
+
// disposes any half-prepared agent), then flush the durable recall, then
|
|
668
|
+
// request exit. `composing` and `historyWriteChain` are read at step run
|
|
669
|
+
// time, so a turn that was still being queued when quit ran is included.
|
|
670
|
+
// A failing step must never skip the remaining cleanup.
|
|
671
|
+
const steps: QuitCleanupStep[] = [
|
|
672
|
+
...(currentSession === undefined || currentActive === undefined
|
|
673
|
+
? []
|
|
674
|
+
: [
|
|
675
|
+
{ name: 'flush', run: async () => { await sessions.flush(currentSession) } },
|
|
676
|
+
{ name: 'dispose', run: () => currentActive.handle.dispose() },
|
|
677
|
+
]),
|
|
678
|
+
{ name: 'composing', run: () => composing ?? Promise.resolve() },
|
|
679
|
+
{ name: 'history', run: () => historyWriteChain },
|
|
680
|
+
]
|
|
681
|
+
void runQuitSequence(steps, io.exit, report)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Run one slash line through the command registry (closed namespace). */
|
|
685
|
+
const runSlash = (line: string): void => {
|
|
686
|
+
const currentAgent = agent
|
|
687
|
+
if (currentAgent === undefined) return
|
|
688
|
+
if (line.startsWith('/resume ')) {
|
|
689
|
+
requestResume(line.slice(8).trim())
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
const registry = ctx.get('commands')
|
|
693
|
+
if (registry === undefined) {
|
|
694
|
+
bridge.notify('no command registry is mounted in this composition', 'error')
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
const controller = new AbortController()
|
|
698
|
+
const atEpoch = epoch
|
|
699
|
+
pendingControllers.add(controller)
|
|
700
|
+
const finish = (): void => {
|
|
701
|
+
pendingControllers.delete(controller)
|
|
702
|
+
}
|
|
703
|
+
// rc.8 registry.execute gained an `images` admission parameter; the TUI
|
|
704
|
+
// composer never attaches images to a slash line, so every invocation is
|
|
705
|
+
// the empty batch (commands declaring input.images still run image-free).
|
|
706
|
+
void Promise.resolve().then(() => registry.execute(currentAgent, line, [], controller.signal)).then((execution) => {
|
|
707
|
+
finish()
|
|
708
|
+
// A switch/quit landed while the command ran: its fall-through must not
|
|
709
|
+
// reach an agent that is no longer on screen.
|
|
710
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
711
|
+
if (execution === undefined) {
|
|
712
|
+
// No command owns this line: send it verbatim so a user-invocable
|
|
713
|
+
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
714
|
+
// pre-step injection — the web composer's same fall-through.
|
|
715
|
+
try {
|
|
716
|
+
currentAgent.followup(createUserMessage({
|
|
717
|
+
content: [{ type: 'text', text: line }],
|
|
718
|
+
source: { kind: 'user' },
|
|
719
|
+
}))
|
|
720
|
+
} catch (error: unknown) {
|
|
721
|
+
bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}, (error: unknown) => {
|
|
725
|
+
finish()
|
|
726
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
727
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
732
|
+
const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
|
|
733
|
+
const currentAgent = agent!
|
|
734
|
+
const currentMentions = mentions!
|
|
735
|
+
// The command registry is a closed namespace: slash lines run out of
|
|
736
|
+
// band and never reach the model through this path (steering keeps the
|
|
737
|
+
// registry out of the inbox, so slash lines steer as literal text).
|
|
738
|
+
if (images.length === 0 && isSlashLine(line) && mode === 'followup') {
|
|
739
|
+
runSlash(line)
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
let parsed: ReturnType<MentionsApi['parse']>
|
|
743
|
+
try {
|
|
744
|
+
parsed = currentMentions.parse(line)
|
|
745
|
+
} catch (error: unknown) {
|
|
746
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
const atEpoch = epoch
|
|
750
|
+
const deliver = (readable: string, context?: UserMessage): void => {
|
|
751
|
+
// A switch/quit landed while the snapshot was being prepared: never
|
|
752
|
+
// deliver to an agent that is no longer on screen.
|
|
753
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
754
|
+
// Session snapshots ride the inbox as model-facing context ahead of
|
|
755
|
+
// the readable message (upstream README wiring: inject before the
|
|
756
|
+
// followup/steer that wakes the driver).
|
|
757
|
+
try {
|
|
758
|
+
if (context !== undefined) currentAgent.inject(context)
|
|
759
|
+
const content: ContentBlock[] = [
|
|
760
|
+
...(readable === '' ? [] : [{ type: 'text' as const, text: readable }]),
|
|
761
|
+
...images,
|
|
762
|
+
]
|
|
763
|
+
const message = createUserMessage({
|
|
764
|
+
content,
|
|
765
|
+
source: { kind: 'user' },
|
|
766
|
+
})
|
|
767
|
+
if (mode === 'steer') {
|
|
768
|
+
// The queued message is visible as a pending transcript row (the
|
|
769
|
+
// web queue-mirror contract); no notice noise on the happy path.
|
|
770
|
+
currentAgent.steer(message)
|
|
771
|
+
} else {
|
|
772
|
+
currentAgent.followup(message)
|
|
773
|
+
}
|
|
774
|
+
} catch (error: unknown) {
|
|
775
|
+
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (parsed.references.length === 0) {
|
|
779
|
+
deliver(parsed.text)
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
const controller = new AbortController()
|
|
783
|
+
pendingControllers.add(controller)
|
|
784
|
+
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
785
|
+
pendingControllers.delete(controller)
|
|
786
|
+
deliver(prepared.text, prepared.additionalContext)
|
|
787
|
+
}, (error: unknown) => {
|
|
788
|
+
pendingControllers.delete(controller)
|
|
789
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
790
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Deferred first-session creation for a bare launch: the session is composed
|
|
795
|
+
// only when the user submits real input (or /new), and every line that
|
|
796
|
+
// arrives during creation is delivered in order afterwards. A creation
|
|
797
|
+
// failure reports and clears the queue, leaving the transient state ready
|
|
798
|
+
// for the next attempt.
|
|
799
|
+
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer'; images: readonly ImageBlock[] }> = []
|
|
800
|
+
// A creation is queued/running: further submissions must not mint more
|
|
801
|
+
// fresh sessions (their lines queue into pendingInputs instead).
|
|
802
|
+
let creating = false
|
|
803
|
+
const ensureSession = (mode?: string): void => {
|
|
804
|
+
if (creating) return
|
|
805
|
+
creating = true
|
|
806
|
+
void compose(async () => {
|
|
807
|
+
try {
|
|
808
|
+
// A direct `/mode <preset>` resolves asynchronously. Preserve submit
|
|
809
|
+
// order so the first composition cannot race ahead with the old mode.
|
|
810
|
+
await pendingModeWork
|
|
811
|
+
// Another composition (e.g. a /resume activated while this creation
|
|
812
|
+
// waited its turn) may have published a session already: deliver the
|
|
813
|
+
// queued lines there instead of minting a competing fresh session
|
|
814
|
+
// (which would orphan the live one without a dispose).
|
|
815
|
+
if (session !== undefined) {
|
|
816
|
+
const queued = pendingInputs.splice(0)
|
|
817
|
+
for (const item of queued) deliverLine(item.text, item.mode, item.images)
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
const next = await prepare({
|
|
821
|
+
sessionId: `session-${randomUUID()}`,
|
|
822
|
+
resume: false,
|
|
823
|
+
...(mode === undefined ? {} : { mode }),
|
|
824
|
+
})
|
|
825
|
+
if (quitting) {
|
|
826
|
+
void next.handle.dispose().catch(() => {})
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
active = next
|
|
830
|
+
agent = next.agent
|
|
831
|
+
session = next.session
|
|
832
|
+
store = next.store
|
|
833
|
+
mentions = next.mentions
|
|
834
|
+
subagents.reset()
|
|
835
|
+
pendingMode = undefined
|
|
836
|
+
pendingPermission = undefined
|
|
837
|
+
commands.setAgent(agent)
|
|
838
|
+
skills.setAgent(agent)
|
|
839
|
+
// The App mounts with a placeholder key until the first input; the
|
|
840
|
+
// key-change remount below must start from a clean screen or the ghost
|
|
841
|
+
// static header stays visible above the new one (same source-backed
|
|
842
|
+
// clear the session-switch path performs).
|
|
843
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
844
|
+
renderCurrent()
|
|
845
|
+
abortPendingControllers()
|
|
846
|
+
epoch += 1
|
|
847
|
+
const queued = pendingInputs.splice(0)
|
|
848
|
+
for (const item of queued) deliverLine(item.text, item.mode, item.images)
|
|
849
|
+
} finally {
|
|
850
|
+
creating = false
|
|
851
|
+
}
|
|
852
|
+
}).catch((error: unknown) => {
|
|
853
|
+
pendingInputs.length = 0
|
|
854
|
+
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
859
|
+
const send = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
|
|
860
|
+
const line = text.trim()
|
|
861
|
+
if (line === '' && images.length === 0) return
|
|
862
|
+
if (images.length === 0 && line.startsWith('/mode ')) {
|
|
863
|
+
void switchModeAction(line.slice(6).trim()).then(
|
|
864
|
+
selected => bridge.notify(`mode → ${selected}`),
|
|
865
|
+
error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
866
|
+
)
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
if (images.length === 0 && line.startsWith('/permission ')) {
|
|
870
|
+
try {
|
|
871
|
+
const selected = setPermissionAction(line.slice(12).trim())
|
|
872
|
+
bridge.notify(`permission → ${selected}`)
|
|
873
|
+
} catch (error: unknown) {
|
|
874
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
875
|
+
}
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
if (session === undefined) {
|
|
879
|
+
pendingInputs.push({ text: line, mode, images })
|
|
880
|
+
ensureSession()
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
deliverLine(line, mode, images)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
887
|
+
const dispatch = (text: string): void => {
|
|
888
|
+
send(text, 'followup')
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Submit steering: a running driver consumes the text at its next step
|
|
893
|
+
* boundary (the inbox delivers between steps); an idle driver just starts
|
|
894
|
+
* a turn, so this doubles as the busy-state submit path.
|
|
895
|
+
*/
|
|
896
|
+
const steer = (text: string): void => {
|
|
897
|
+
send(text, 'steer')
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
901
|
+
const interrupt = (): boolean => {
|
|
902
|
+
if (agent === undefined || agent.status !== 'running') return false
|
|
903
|
+
try {
|
|
904
|
+
agent.cancel({ kind: 'user' })
|
|
905
|
+
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
906
|
+
return true
|
|
907
|
+
} catch (error: unknown) {
|
|
908
|
+
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
909
|
+
return false
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Select one permission preset before the first session or on the active one. */
|
|
914
|
+
const setPermissionAction = (id: string): string => {
|
|
915
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
916
|
+
throw new Error('permission presets are not mounted in this composition')
|
|
917
|
+
}
|
|
918
|
+
if (id === '') throw new Error('usage: /permission <preset>')
|
|
919
|
+
const selected = selectPermission(permissionPresets, session, id)
|
|
920
|
+
if (session === undefined) {
|
|
921
|
+
pendingPermission = selected
|
|
922
|
+
renderCurrent()
|
|
923
|
+
}
|
|
924
|
+
return selected
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Cycle to the next permission preset (Shift+Tab). Before the first session,
|
|
929
|
+
* the choice remains process-local and is materialized when Harness creates
|
|
930
|
+
* that session; afterwards the canonical service writes durable events.
|
|
931
|
+
*/
|
|
932
|
+
const cyclePermission = (): string => {
|
|
933
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
934
|
+
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
935
|
+
return ''
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
|
|
939
|
+
if (session === undefined && next !== '') {
|
|
940
|
+
pendingPermission = next
|
|
941
|
+
renderCurrent()
|
|
942
|
+
}
|
|
943
|
+
return next
|
|
944
|
+
} catch (error: unknown) {
|
|
945
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
946
|
+
return ''
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Apply one /model selection: takes effect from the next assembled step.
|
|
952
|
+
* The optional reasoning effort must be one the row advertises (the picker
|
|
953
|
+
* only offers those), so an unsupported value cannot reach the request
|
|
954
|
+
* pipeline; an absent effort restores the model's own default.
|
|
955
|
+
*/
|
|
956
|
+
const selectModel = (row: ModelRow, effortId?: string): string => {
|
|
957
|
+
const selection = buildModelSelection(row, effortId)
|
|
958
|
+
if (active === undefined) {
|
|
959
|
+
// A bare launch has no session yet: keep the pick process-wide so the
|
|
960
|
+
// first composed session starts from it.
|
|
961
|
+
pendingSelection = selection
|
|
962
|
+
} else {
|
|
963
|
+
active.selection.picked = selection
|
|
964
|
+
}
|
|
965
|
+
// Global default (web selectModel parity): every pick is persisted as the
|
|
966
|
+
// deployment default through the same agentDefaultModel service the web
|
|
967
|
+
// host writes, so the choice survives restarts and other surfaces read
|
|
968
|
+
// it. Save failures degrade to a notice — the in-session switch already
|
|
969
|
+
// took effect and must not roll back (the web contract).
|
|
970
|
+
void defaultModel.saveSelection(selection).catch((error: unknown) => {
|
|
971
|
+
bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
|
972
|
+
})
|
|
973
|
+
// Advisory immediate validation (web selectModel parity): run the same
|
|
974
|
+
// local resolveCallConfig check the request pipeline would, so a stale
|
|
975
|
+
// directory — an effort the adapter withdrew since /model loaded —
|
|
976
|
+
// surfaces as a pick-time notice instead of failing the next assembled
|
|
977
|
+
// step. Best-effort: an llm service without the resolver keeps the
|
|
978
|
+
// existing request-boundary rejection. Called as a method (`this`-bound)
|
|
979
|
+
// like resolveModelInfo in models.ts.
|
|
980
|
+
const llm = ctx.get('llm')
|
|
981
|
+
const resolveCallConfig = (llm as {
|
|
982
|
+
resolveCallConfig?: (this: unknown, config: { provider: string; model: string; reasoningEffort?: string }) => Promise<unknown>
|
|
983
|
+
} | undefined)?.resolveCallConfig
|
|
984
|
+
if (llm !== undefined && typeof resolveCallConfig === 'function') {
|
|
985
|
+
void Promise.resolve(resolveCallConfig.call(llm, {
|
|
986
|
+
provider: selection.provider,
|
|
987
|
+
model: selection.model,
|
|
988
|
+
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
989
|
+
})).catch((error: unknown) => {
|
|
990
|
+
bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
|
|
991
|
+
})
|
|
992
|
+
}
|
|
993
|
+
return `${row.provider}/${row.model}`
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
997
|
+
const subagentModelLabel = (): string => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride)
|
|
998
|
+
|
|
999
|
+
/** Apply one /subagent model pick; returns the override label. */
|
|
1000
|
+
const setSubagentModel = (row: ModelRow, effortId?: string): string => {
|
|
1001
|
+
subagentOverride = buildModelSelection(row, effortId)
|
|
1002
|
+
renderCurrent()
|
|
1003
|
+
return modelSelectionLabel(subagentOverride)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Drop the /subagent override: delegated agents follow the current model again. */
|
|
1007
|
+
const clearSubagentModel = (): void => {
|
|
1008
|
+
subagentOverride = undefined
|
|
1009
|
+
renderCurrent()
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Export the folded transcript to a markdown file (/export). The default
|
|
1014
|
+
* target sits beside the session's cwd so the file lands in the user's
|
|
1015
|
+
* workspace; an absolute or cwd-relative argument overrides it.
|
|
1016
|
+
*/
|
|
1017
|
+
const exportTranscript = async (argument: string): Promise<void> => {
|
|
1018
|
+
if (session === undefined) {
|
|
1019
|
+
bridge.notify('no session yet — submit a message to start', 'warning')
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
const wanted = argument.trim()
|
|
1023
|
+
const sessionCwd = session.header.cwd ?? cwd
|
|
1024
|
+
// The default name derives from the session id, which `--session` lets the
|
|
1025
|
+
// user spell freely: reduce it to filename-safe characters first so the
|
|
1026
|
+
// default target can never escape the session cwd.
|
|
1027
|
+
const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
|
|
1028
|
+
const target = wanted === ''
|
|
1029
|
+
? join(sessionCwd, defaultName)
|
|
1030
|
+
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
1031
|
+
? wanted
|
|
1032
|
+
: join(sessionCwd, wanted)
|
|
1033
|
+
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
1034
|
+
try {
|
|
1035
|
+
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
1036
|
+
bridge.notify(`exported to ${target}`)
|
|
1037
|
+
} catch (error: unknown) {
|
|
1038
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Rename the session (/title): a user title pins the session and stops
|
|
1044
|
+
* automatic generation (the service's own contract). The appended
|
|
1045
|
+
* `session/title` event flows back through the store into the status line.
|
|
1046
|
+
*/
|
|
1047
|
+
const renameTitle = (argument: string): string => {
|
|
1048
|
+
const title = argument.trim()
|
|
1049
|
+
if (title === '') return 'usage: /title <text>'
|
|
1050
|
+
if (session === undefined) return 'no session yet — submit a message to start'
|
|
1051
|
+
const service = ctx.get('sessionTitle')
|
|
1052
|
+
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
1053
|
+
try {
|
|
1054
|
+
service.rename(session, title)
|
|
1055
|
+
return `title → ${title}`
|
|
1056
|
+
} catch (error: unknown) {
|
|
1057
|
+
return `rename failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
1062
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1063
|
+
const records = await sessionQuery.listSessions(signal)
|
|
1064
|
+
// Last-activity timestamps for sorting (codex UpdatedAt default): the
|
|
1065
|
+
// JSONL artifact's mtime via locate()+stat — the upstream api-proxy's own
|
|
1066
|
+
// cold-probe pattern. O(1) per session; backends without a location (or
|
|
1067
|
+
// vanished files) fall back to createdAt inside the projection.
|
|
1068
|
+
const updated = new Map<string, number>()
|
|
1069
|
+
for (const record of records) {
|
|
1070
|
+
const location = persistence?.locate(record.header)
|
|
1071
|
+
if (location === undefined) continue
|
|
1072
|
+
try {
|
|
1073
|
+
updated.set(record.header.id, (await stat(location.path)).mtimeMs)
|
|
1074
|
+
} catch {
|
|
1075
|
+
// Artifact gone or unreadable: the projection falls back to createdAt.
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const projected = projectSessionRows(records, options, updated)
|
|
1079
|
+
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
1080
|
+
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
1081
|
+
const page = projected.slice(0, 32)
|
|
1082
|
+
if (page.length === 0) return projected
|
|
1083
|
+
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
1084
|
+
return mergeSessionTitles(projected, observations)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Delete one session subtree (/delete, codex semantics: subagent threads go
|
|
1089
|
+
* with their root). The kernel persistence seam has NO deletion API by
|
|
1090
|
+
* design — logs accumulate "until removed externally" — so this is the
|
|
1091
|
+
* controlled external removal: guards (live/current refusal, subtree
|
|
1092
|
+
* collection, and the JSONL layout check `encodeSegment(id)/session.jsonl`)
|
|
1093
|
+
* run before any filesystem touch, and only the backend-located artifacts
|
|
1094
|
+
* are removed. Backends without a locatable artifact (SQLite) are refused.
|
|
1095
|
+
* @param id - the root session id to delete.
|
|
1096
|
+
* @returns the outcome line for the panel/notice.
|
|
1097
|
+
*/
|
|
1098
|
+
const deleteSession = async (id: string): Promise<string> => {
|
|
1099
|
+
if (sessionQuery === undefined) return 'session query is unavailable in this profile'
|
|
1100
|
+
if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
|
|
1101
|
+
const records = await sessionQuery.listSessions()
|
|
1102
|
+
const target = records.find(record => record.header.id === id)
|
|
1103
|
+
if (target === undefined) return `no persisted session matches "${id}"`
|
|
1104
|
+
if (target.live) return 'cannot delete a live session — it is open in this or another process'
|
|
1105
|
+
const doomed = collectDeletionSubtree(records, id)
|
|
1106
|
+
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
1107
|
+
let removed = 0
|
|
1108
|
+
for (const candidate of doomed) {
|
|
1109
|
+
const record = byId.get(candidate)
|
|
1110
|
+
if (record === undefined || record.live) continue
|
|
1111
|
+
const location = persistence?.locate(record.header)
|
|
1112
|
+
if (location === undefined) {
|
|
1113
|
+
return `session backend exposes no deletable artifact for ${candidate.slice(-12)} (deletion is unsupported on this backend)`
|
|
1114
|
+
}
|
|
1115
|
+
const dir = sessionArtifactDirectory(location.path, candidate)
|
|
1116
|
+
if (dir === undefined) {
|
|
1117
|
+
return `refusing to delete: unexpected artifact layout at ${location.path}`
|
|
1118
|
+
}
|
|
1119
|
+
try {
|
|
1120
|
+
for (const name of SESSION_ARTIFACT_NAMES) {
|
|
1121
|
+
await rm(join(dir, name), { force: true })
|
|
1122
|
+
}
|
|
1123
|
+
// Remove the now-empty session directory; a non-empty one stays (an
|
|
1124
|
+
// unexpected sibling file is never ours to delete).
|
|
1125
|
+
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1126
|
+
removed += 1
|
|
1127
|
+
} catch (error: unknown) {
|
|
1128
|
+
return `delete failed for ${candidate.slice(-12)}: ${error instanceof Error ? error.message : String(error)}`
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
1135
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1136
|
+
const snapshot = await sessionQuery.readSession(id, signal)
|
|
1137
|
+
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const switchModeAction = async (id: string): Promise<string> => {
|
|
1141
|
+
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1142
|
+
const currentAgent = agent
|
|
1143
|
+
if (currentAgent === undefined) {
|
|
1144
|
+
const choice = pendingModeWork.then(async () => {
|
|
1145
|
+
const preset = await selectPreset(presets, undefined, id)
|
|
1146
|
+
// A resume may have won while this roster read was in flight; never
|
|
1147
|
+
// leak the old pending choice into a later /new session.
|
|
1148
|
+
if (agent === undefined) {
|
|
1149
|
+
pendingMode = preset.id
|
|
1150
|
+
renderCurrent()
|
|
1151
|
+
}
|
|
1152
|
+
return preset.id
|
|
1153
|
+
})
|
|
1154
|
+
pendingModeWork = choice.then(() => {}, () => {})
|
|
1155
|
+
return choice
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const preset = await selectPreset(presets, currentAgent, id)
|
|
1159
|
+
if (active === undefined) throw new Error('active Agent has no session state')
|
|
1160
|
+
active.mode = preset.id
|
|
1161
|
+
commands.setAgent(currentAgent)
|
|
1162
|
+
skills.setAgent(currentAgent)
|
|
1163
|
+
renderCurrent()
|
|
1164
|
+
return preset.id
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
1168
|
+
|
|
1169
|
+
const activate = (nextTarget: Target): Promise<void> => {
|
|
1170
|
+
if (quitting) return Promise.resolve()
|
|
1171
|
+
// Serialized with every other composition (bare-launch creation, queued
|
|
1172
|
+
// switches): at most one agent is composed at a time.
|
|
1173
|
+
return compose(async () => {
|
|
1174
|
+
const previous = active
|
|
1175
|
+
const next = await prepare(nextTarget)
|
|
1176
|
+
// Quit landed while the next session was being composed: dispose the
|
|
1177
|
+
// half-ready agent and leave the current session untouched.
|
|
1178
|
+
if (quitting) {
|
|
1179
|
+
await next.handle.dispose().catch(() => {})
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1182
|
+
active = next
|
|
1183
|
+
agent = next.agent
|
|
1184
|
+
session = next.session
|
|
1185
|
+
store = next.store
|
|
1186
|
+
mentions = next.mentions
|
|
1187
|
+
subagents.reset()
|
|
1188
|
+
pendingMode = undefined
|
|
1189
|
+
pendingPermission = undefined
|
|
1190
|
+
commands.setAgent(agent)
|
|
1191
|
+
skills.setAgent(agent)
|
|
1192
|
+
try {
|
|
1193
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1194
|
+
renderCurrent()
|
|
1195
|
+
} catch (error: unknown) {
|
|
1196
|
+
active = previous
|
|
1197
|
+
agent = previous?.agent
|
|
1198
|
+
session = previous?.session
|
|
1199
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
1200
|
+
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1201
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
1202
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
1203
|
+
await next.handle.dispose()
|
|
1204
|
+
if (!quitting) renderCurrent()
|
|
1205
|
+
throw error
|
|
1206
|
+
}
|
|
1207
|
+
// From here the new session is live: in-flight prepares/commands for
|
|
1208
|
+
// the previous agent are stale and must be aborted and ignored.
|
|
1209
|
+
abortPendingControllers()
|
|
1210
|
+
epoch += 1
|
|
1211
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
1212
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
1213
|
+
if (previous === undefined) {
|
|
1214
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
1215
|
+
return
|
|
1216
|
+
}
|
|
1217
|
+
let cleanupWarning: string | undefined
|
|
1218
|
+
try {
|
|
1219
|
+
await sessions.flush(previous.session)
|
|
1220
|
+
} catch (error: unknown) {
|
|
1221
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
await previous.handle.dispose()
|
|
1225
|
+
} catch (error: unknown) {
|
|
1226
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1227
|
+
}
|
|
1228
|
+
bridge.notify(cleanupWarning === undefined
|
|
1229
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
1230
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
1231
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1232
|
+
})
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
1236
|
+
async request => { if (!quitting) await activate(request.target) },
|
|
1237
|
+
error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
const requestSwitch = (request: PendingSwitch): void => {
|
|
1241
|
+
if (session === undefined) {
|
|
1242
|
+
// No session yet (a bare launch using /resume before any input): activate
|
|
1243
|
+
// the target directly — there is no running turn to wait on and nothing
|
|
1244
|
+
// to flush.
|
|
1245
|
+
void activate(request.target).catch((error: unknown) => {
|
|
1246
|
+
bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1247
|
+
})
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
1250
|
+
if (request.target.sessionId === session.id) {
|
|
1251
|
+
bridge.notify('that session is already active', 'warning')
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1254
|
+
const outcome = switchQueue.request(agent!, request)
|
|
1255
|
+
if (outcome === 'queued') {
|
|
1256
|
+
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const resolveResumeId = async (wanted: string): Promise<string> => {
|
|
1261
|
+
if (wanted === '') throw new Error('usage: /resume <id|prefix>')
|
|
1262
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1263
|
+
const records = await sessionQuery.listSessions()
|
|
1264
|
+
const exact = records.filter(record => record.header.id === wanted)
|
|
1265
|
+
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
1266
|
+
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
1267
|
+
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
1268
|
+
const matched = matches[0]!
|
|
1269
|
+
// Same lineage gate as the CLI --resume path and the picker.
|
|
1270
|
+
if (isSubagentSession(matched.header)) {
|
|
1271
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
1272
|
+
}
|
|
1273
|
+
if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
|
|
1274
|
+
throw new Error('that session is already live in another owner')
|
|
1275
|
+
}
|
|
1276
|
+
return matched.header.id
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const requestResume = (wanted: string): void => {
|
|
1280
|
+
void resolveResumeId(wanted).then(id => {
|
|
1281
|
+
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
1282
|
+
}, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const createSession = (mode?: string): void => {
|
|
1286
|
+
// /new before any input is the first-session creation itself, not a switch.
|
|
1287
|
+
if (session === undefined) {
|
|
1288
|
+
ensureSession(mode)
|
|
1289
|
+
return
|
|
1290
|
+
}
|
|
1291
|
+
const nextCwd = session.header.cwd ?? cwd
|
|
1292
|
+
const id = `session-${randomUUID()}`
|
|
1293
|
+
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const reviewChanges = (argument: string): void => {
|
|
1297
|
+
void loadGitDiff(session?.header.cwd ?? cwd, argument).then(({ title, files }) => {
|
|
1298
|
+
try {
|
|
1299
|
+
setPermissionAction('read-only')
|
|
1300
|
+
} catch (error: unknown) {
|
|
1301
|
+
bridge.notify(`review unavailable: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title), 'followup')
|
|
1305
|
+
bridge.notify('review started under read-only permissions')
|
|
1306
|
+
}, (error: unknown) => {
|
|
1307
|
+
bridge.notify(`review failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1308
|
+
})
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const forkSession = (argument: string): void => {
|
|
1312
|
+
if (session === undefined || active === undefined) {
|
|
1313
|
+
bridge.notify('no session yet - submit a message to start', 'warning')
|
|
1314
|
+
return
|
|
1315
|
+
}
|
|
1316
|
+
try {
|
|
1317
|
+
const text = argument.trim()
|
|
1318
|
+
const atSeq = text === '' ? undefined : Number(text)
|
|
1319
|
+
if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
|
|
1320
|
+
throw new Error('usage: /fork [event-seq]')
|
|
1321
|
+
}
|
|
1322
|
+
const seed = selectForkSeed(session.events, atSeq)
|
|
1323
|
+
const id = `session-${randomUUID()}`
|
|
1324
|
+
requestSwitch({
|
|
1325
|
+
target: {
|
|
1326
|
+
sessionId: id,
|
|
1327
|
+
resume: false,
|
|
1328
|
+
mode: active.mode,
|
|
1329
|
+
cwd: session.header.cwd ?? cwd,
|
|
1330
|
+
seed: seed.events,
|
|
1331
|
+
parentSession: session.id,
|
|
1332
|
+
seedLength: seed.events.length,
|
|
1333
|
+
},
|
|
1334
|
+
label: id.slice(-12),
|
|
1335
|
+
})
|
|
1336
|
+
} catch (error: unknown) {
|
|
1337
|
+
bridge.notify(`fork failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const switchSession = (row: SessionRow): void => {
|
|
1342
|
+
if (!row.resumable) {
|
|
1343
|
+
bridge.notify('subagent conversations are read-only', 'warning')
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const cancelSessionSwitch = (): boolean => {
|
|
1350
|
+
return switchQueue.cancel()
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const appElement = (): ReturnType<typeof createElement> => {
|
|
1354
|
+
// A bare launch mounts with pending/default model, mode, and permission
|
|
1355
|
+
// facts until the first input composes the real session. These choices stay
|
|
1356
|
+
// process-local and create no durable state before that composition.
|
|
1357
|
+
const sessionCwd = session?.header.cwd ?? cwd
|
|
1358
|
+
const currentView = store.getView()
|
|
1359
|
+
const defaults = currentDefaults()
|
|
1360
|
+
const model = currentView.model !== ''
|
|
1361
|
+
? currentView.model
|
|
1362
|
+
: pendingSelection !== undefined
|
|
1363
|
+
? `${pendingSelection.provider}/${pendingSelection.model}`
|
|
1364
|
+
: `${defaults.provider}/${defaults.model}`
|
|
1365
|
+
const effort = resolveEffectiveSelection(
|
|
1366
|
+
active?.selection.picked ?? pendingSelection,
|
|
1367
|
+
session?.requestHeader()?.config,
|
|
1368
|
+
defaults,
|
|
1369
|
+
).reasoningEffort
|
|
1370
|
+
const permission = permissionPresets === undefined
|
|
1371
|
+
? currentView.permission
|
|
1372
|
+
: effectivePermission(permissionPresets, session, pendingPermission)
|
|
1373
|
+
return createElement(App, {
|
|
1374
|
+
key: session?.id ?? 'pending',
|
|
1375
|
+
store,
|
|
1376
|
+
approval,
|
|
1377
|
+
questions,
|
|
1378
|
+
subagents,
|
|
1379
|
+
commands,
|
|
1380
|
+
skills,
|
|
1381
|
+
model,
|
|
1382
|
+
effort,
|
|
1383
|
+
cwd: basename(sessionCwd),
|
|
1384
|
+
workspaceRoot: sessionCwd,
|
|
1385
|
+
branch: gitBranch(sessionCwd),
|
|
1386
|
+
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
1387
|
+
resumed: active?.resumed ?? false,
|
|
1388
|
+
mode: active?.mode ?? pendingMode ?? presets.defaultId,
|
|
1389
|
+
permission,
|
|
1390
|
+
dispatch,
|
|
1391
|
+
steer,
|
|
1392
|
+
interrupt,
|
|
1393
|
+
quit,
|
|
1394
|
+
loadModels: () => loadModelDirectory(ctx),
|
|
1395
|
+
loadModelProviders: () => loadProviderSettings(ctx),
|
|
1396
|
+
subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
|
|
1397
|
+
saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
|
|
1398
|
+
saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
|
|
1399
|
+
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
1400
|
+
removeModelProvider: target => removeProviderSettings(ctx, target),
|
|
1401
|
+
loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
|
|
1402
|
+
cyclePermission,
|
|
1403
|
+
setPermission: setPermissionAction,
|
|
1404
|
+
selectModel,
|
|
1405
|
+
subagentModel: subagentModelLabel(),
|
|
1406
|
+
setSubagentModel,
|
|
1407
|
+
clearSubagentModel,
|
|
1408
|
+
deleteSession,
|
|
1409
|
+
exportTranscript,
|
|
1410
|
+
renameTitle,
|
|
1411
|
+
copyLastResponse,
|
|
1412
|
+
loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
|
|
1413
|
+
reviewChanges,
|
|
1414
|
+
loadPresets: () => presets.list(),
|
|
1415
|
+
switchMode: switchModeAction,
|
|
1416
|
+
loadPermissions: () => permissionPresets === undefined
|
|
1417
|
+
? Promise.reject(new Error('permission presets are not mounted in this composition'))
|
|
1418
|
+
: Promise.resolve(listPermissionRows(permissionPresets)),
|
|
1419
|
+
createSession,
|
|
1420
|
+
forkSession,
|
|
1421
|
+
loadSessions,
|
|
1422
|
+
loadSessionTranscript,
|
|
1423
|
+
loadSubagents: () => {
|
|
1424
|
+
const current = session
|
|
1425
|
+
if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
|
|
1426
|
+
return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
|
|
1427
|
+
.then(rows => rows.filter(row => row.parent === current.id && row.subagent))
|
|
1428
|
+
},
|
|
1429
|
+
switchSession,
|
|
1430
|
+
cancelSessionSwitch,
|
|
1431
|
+
loadPlugins: () => listPluginRows(ctx),
|
|
1432
|
+
loadJobs: () => listJobs(ctx, active?.agent),
|
|
1433
|
+
statusline: statuslineItems,
|
|
1434
|
+
saveStatusline,
|
|
1435
|
+
saveTheme,
|
|
1436
|
+
history: inputHistory,
|
|
1437
|
+
recordHistory,
|
|
1438
|
+
cancelQueued,
|
|
1439
|
+
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
1440
|
+
})
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const renderCurrent = (): void => {
|
|
1444
|
+
mountRef.current?.rerender(appElement())
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
mountRef.current = io.mount(appElement())
|
|
1448
|
+
|
|
1449
|
+
// Startup prompt/images use the same durable delivery path as composer
|
|
1450
|
+
// submissions. Image bytes are committed before the user/message event.
|
|
1451
|
+
if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
|
|
1452
|
+
void saveImagePaths(startup.images ?? [], ctx.get('attachments')).then(
|
|
1453
|
+
images => send(startup.prompt ?? '', 'followup', images),
|
|
1454
|
+
(error: unknown) => bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
1455
|
+
)
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async function copyLastResponse(): Promise<string> {
|
|
1459
|
+
const text = latestAssistantText(store.getView())
|
|
1460
|
+
if (text === undefined) return 'nothing to copy yet'
|
|
1461
|
+
await copyText(text)
|
|
1462
|
+
return 'copied latest response'
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// A corrupt statusline config must not vanish silently: surface it once
|
|
1466
|
+
// the notice channel is live, after the first frame settles.
|
|
1467
|
+
if (statuslineWarning !== undefined) {
|
|
1468
|
+
setTimeout(() => {
|
|
1469
|
+
bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
|
|
1470
|
+
}, 50)
|
|
1471
|
+
}
|
|
1472
|
+
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
1473
|
+
if (themeWarning !== undefined) {
|
|
1474
|
+
setTimeout(() => {
|
|
1475
|
+
bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
|
|
1476
|
+
}, 50)
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Mount the interactive terminal driver.
|
|
1482
|
+
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
1483
|
+
* @param config - validated startup config resolved from the tuiStartup provider.
|
|
1484
|
+
*/
|
|
1485
|
+
export function apply(ctx: Context, config: Config): void {
|
|
1486
|
+
// The CLI validated --theme at parse time; the loose config schema falls
|
|
1487
|
+
// back to dark for anything unexpected.
|
|
1488
|
+
const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
|
|
1489
|
+
const input = {
|
|
1490
|
+
...(theme === undefined ? {} : { theme }),
|
|
1491
|
+
...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
|
|
1492
|
+
...(config.startup.images === undefined ? {} : { images: config.startup.images }),
|
|
1493
|
+
}
|
|
1494
|
+
const startup: TuiStartup =
|
|
1495
|
+
config.startup.kind === 'resume' && config.startup.sessionId !== undefined
|
|
1496
|
+
? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
|
|
1497
|
+
: config.startup.kind === 'latest'
|
|
1498
|
+
? { kind: 'latest', ...input }
|
|
1499
|
+
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
1500
|
+
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
1501
|
+
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
1502
|
+
// Read through the global service store, not the property proxy: appExit is
|
|
1503
|
+
// an optional host value, never an injected dependency.
|
|
1504
|
+
const exit = ctx.get('appExit')
|
|
1505
|
+
if (exit === undefined) {
|
|
1506
|
+
throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
|
|
1507
|
+
}
|
|
1508
|
+
const io: TuiIo = { mount: internals.mount, exit }
|
|
1509
|
+
void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
|
|
1510
|
+
}
|