dsh-code 1.0.5 → 1.0.7
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 +338 -286
- package/README.md +68 -16
- package/bin/deepseek.mjs +204 -4
- package/cordis.patch.yml +105 -7
- package/lib/index.mjs +2148 -439
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +34 -8
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/index.d.ts +38 -2
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +80 -4
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/store.d.ts +3 -0
- package/lib/types/terminal-title.d.ts +58 -0
- package/lib/types/update-panel.d.ts +49 -0
- package/lib/types/update.d.ts +66 -0
- package/package.json +307 -162
- package/src/app.ts +730 -266
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/index.ts +1986 -1779
- package/src/internals.ts +66 -40
- package/src/kernel-panels.ts +89 -3
- package/src/provider-settings.ts +12 -12
- package/src/render/animations.ts +606 -403
- package/src/render/editor.ts +5 -4
- package/src/render/export.ts +13 -3
- package/src/render/fuzzy.ts +83 -0
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +1974 -1621
- package/src/render/status.ts +18 -4
- package/src/session-directory.ts +94 -16
- package/src/session-query.ts +235 -0
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
package/src/index.ts
CHANGED
|
@@ -1,1779 +1,1986 @@
|
|
|
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 { appendFile as appendFileAsync, 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
|
|
25
|
-
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
26
|
-
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
27
|
-
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
28
|
-
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
29
|
-
import type {} from '@deepseek-ai/dsh-session-title'
|
|
30
|
-
// Empty type imports carry the loader Context merge for the settlement await
|
|
31
|
-
// and the cmdline Context merge for the appExit host value.
|
|
32
|
-
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
33
|
-
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
34
|
-
import { App, type NoticeTone } from './app.ts'
|
|
35
|
-
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
36
|
-
import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
|
|
37
|
-
import { internals, type TuiMount } from './internals.ts'
|
|
38
|
-
import { syncModelCapabilities } from './model-capabilities.ts'
|
|
39
|
-
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
40
|
-
import {
|
|
41
|
-
discoverProviderModels,
|
|
42
|
-
loadProviderSettings,
|
|
43
|
-
removeProviderSettings,
|
|
44
|
-
saveProviderCredential,
|
|
45
|
-
saveProviderConfiguration,
|
|
46
|
-
subscribeProviderSettings,
|
|
47
|
-
unsetProviderCredential,
|
|
48
|
-
} from './provider-settings.ts'
|
|
49
|
-
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
50
|
-
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
51
|
-
// Type-only import merges the settings Events declarations ('settings/updated',
|
|
52
|
-
// 'settings/document-updated') into this program's Cordis bus typing.
|
|
53
|
-
import type {} from '@deepseek-ai/dsh-settings'
|
|
54
|
-
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
55
|
-
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
56
|
-
import { parseStatuslineItems } from './render/status.ts'
|
|
57
|
-
import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
58
|
-
import { watchSkills, type SkillsView } from './skills.ts'
|
|
59
|
-
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
60
|
-
import { buildExportMarkdown } from './render/export.ts'
|
|
61
|
-
import { inspectImagePaths, saveImagePaths } from './attachments.ts'
|
|
62
|
-
import { copyText, latestAssistantText } from './editor.ts'
|
|
63
|
-
import { applyCtrlRPassthrough, resolveEditorKeysStartupHint, type EditorKeysEnv } from './editor-keys.ts'
|
|
64
|
-
import {
|
|
65
|
-
beginProviderAuthorization,
|
|
66
|
-
cancelProviderAuthorization,
|
|
67
|
-
loadProviderAuthorizations,
|
|
68
|
-
logoutProviderAuthorization,
|
|
69
|
-
openAuthorizationUrl,
|
|
70
|
-
subscribeProviderAuthorizations,
|
|
71
|
-
} from './authorization.ts'
|
|
72
|
-
import { selectForkSeed } from './fork.ts'
|
|
73
|
-
import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
|
|
74
|
-
import type { TuiStartup } from './startup.ts'
|
|
75
|
-
import { SessionSwitchQueue } from './session-switch.ts'
|
|
76
|
-
import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
|
|
77
|
-
import {
|
|
78
|
-
applyPendingPermission,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
} from './
|
|
85
|
-
import {
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
export const
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
export
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
* @param
|
|
221
|
-
*
|
|
222
|
-
* @
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
return
|
|
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
|
-
|
|
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
|
-
const
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if (
|
|
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
|
-
if (
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
?
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
try {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
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 { appendFile as appendFileAsync, mkdir, readdir, 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 } from '@deepseek-ai/dsh-llm'
|
|
25
|
+
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
26
|
+
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
27
|
+
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
28
|
+
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
29
|
+
import type {} from '@deepseek-ai/dsh-session-title'
|
|
30
|
+
// Empty type imports carry the loader Context merge for the settlement await
|
|
31
|
+
// and the cmdline Context merge for the appExit host value.
|
|
32
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
33
|
+
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
34
|
+
import { App, type NoticeTone } from './app.ts'
|
|
35
|
+
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
36
|
+
import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
|
|
37
|
+
import { internals, type TuiMount } from './internals.ts'
|
|
38
|
+
import { syncModelCapabilities } from './model-capabilities.ts'
|
|
39
|
+
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
40
|
+
import {
|
|
41
|
+
discoverProviderModels,
|
|
42
|
+
loadProviderSettings,
|
|
43
|
+
removeProviderSettings,
|
|
44
|
+
saveProviderCredential,
|
|
45
|
+
saveProviderConfiguration,
|
|
46
|
+
subscribeProviderSettings,
|
|
47
|
+
unsetProviderCredential,
|
|
48
|
+
} from './provider-settings.ts'
|
|
49
|
+
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
50
|
+
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
51
|
+
// Type-only import merges the settings Events declarations ('settings/updated',
|
|
52
|
+
// 'settings/document-updated') into this program's Cordis bus typing.
|
|
53
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
54
|
+
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
55
|
+
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
56
|
+
import { parseStatuslineItems } from './render/status.ts'
|
|
57
|
+
import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
58
|
+
import { watchSkills, type SkillsView } from './skills.ts'
|
|
59
|
+
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
60
|
+
import { buildExportMarkdown } from './render/export.ts'
|
|
61
|
+
import { inspectFilePaths, inspectImagePaths, saveFilePaths, saveImagePaths } from './attachments.ts'
|
|
62
|
+
import { copyText, latestAssistantText } from './editor.ts'
|
|
63
|
+
import { applyCtrlRPassthrough, resolveEditorKeysStartupHint, type EditorKeysEnv } from './editor-keys.ts'
|
|
64
|
+
import {
|
|
65
|
+
beginProviderAuthorization,
|
|
66
|
+
cancelProviderAuthorization,
|
|
67
|
+
loadProviderAuthorizations,
|
|
68
|
+
logoutProviderAuthorization,
|
|
69
|
+
openAuthorizationUrl,
|
|
70
|
+
subscribeProviderAuthorizations,
|
|
71
|
+
} from './authorization.ts'
|
|
72
|
+
import { selectForkSeed } from './fork.ts'
|
|
73
|
+
import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
|
|
74
|
+
import type { TuiStartup } from './startup.ts'
|
|
75
|
+
import { SessionSwitchQueue } from './session-switch.ts'
|
|
76
|
+
import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
|
|
77
|
+
import {
|
|
78
|
+
applyPendingPermission,
|
|
79
|
+
effectivePermission,
|
|
80
|
+
listPermissionRows,
|
|
81
|
+
permissionPresetsFrom,
|
|
82
|
+
selectPermission,
|
|
83
|
+
} from './permissions.ts'
|
|
84
|
+
import { listPluginRows } from './plugin-inventory.ts'
|
|
85
|
+
import { applyLauncherUpdate, probeLauncherUpdate } from './update.ts'
|
|
86
|
+
import { parseAnimationsPref } from './render/animations.ts'
|
|
87
|
+
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
88
|
+
import {
|
|
89
|
+
isSubagentSession,
|
|
90
|
+
matchSessionId,
|
|
91
|
+
mergeSessionTitles,
|
|
92
|
+
newestRootForCwd,
|
|
93
|
+
isSessionArtifactName,
|
|
94
|
+
jsonlSessionRoot,
|
|
95
|
+
planSessionDeletion,
|
|
96
|
+
projectSessionRows,
|
|
97
|
+
sessionArtifactDirectory,
|
|
98
|
+
sessionDirectoryFor,
|
|
99
|
+
type SessionDirectoryOptions,
|
|
100
|
+
type SessionQueryService,
|
|
101
|
+
type SessionRow,
|
|
102
|
+
} from './session-directory.ts'
|
|
103
|
+
import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
|
|
104
|
+
|
|
105
|
+
/** Stable Cordis plugin name. */
|
|
106
|
+
export const name = 'tui-runner'
|
|
107
|
+
|
|
108
|
+
/** Core services required before the interactive session can start. */
|
|
109
|
+
export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
|
110
|
+
|
|
111
|
+
/** Plugin config: the startup resolved from this app's injected provider service. */
|
|
112
|
+
export interface Config {
|
|
113
|
+
/** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
|
|
114
|
+
startup: { kind: string; sessionId?: string; mode?: string; theme?: string; prompt?: string; images?: string[] }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const Config: z<Config> = z.object({
|
|
118
|
+
startup: z.object({
|
|
119
|
+
kind: z.string().required(),
|
|
120
|
+
sessionId: z.string(),
|
|
121
|
+
mode: z.string(),
|
|
122
|
+
theme: z.string(),
|
|
123
|
+
prompt: z.string(),
|
|
124
|
+
images: z.array(z.string()),
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
|
|
129
|
+
interface TuiIo {
|
|
130
|
+
mount: typeof internals.mount
|
|
131
|
+
exit(code: number): void
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
135
|
+
function fail(io: TuiIo, error: unknown): void {
|
|
136
|
+
internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
137
|
+
io.exit(1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Snapshot caller-visible background jobs for the /jobs panel. Jobs the agent
|
|
142
|
+
* started through run_in_background are fenced by their owner, so the CURRENT
|
|
143
|
+
* agent is the caller. A missing registry is a harmless absence (the base
|
|
144
|
+
* composition may not mount one) and collapses to the empty panel state —
|
|
145
|
+
* the documented degradation for harmless probes, not an error.
|
|
146
|
+
* @param ctx - context carrying the optional `jobs` registry.
|
|
147
|
+
* @param caller - the active agent (undefined sees only unowned jobs).
|
|
148
|
+
* @returns job rows in registration order; never throws.
|
|
149
|
+
*/
|
|
150
|
+
function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./kernel-panels.ts').JobRow[] {
|
|
151
|
+
const jobs = ctx.get('jobs')
|
|
152
|
+
if (jobs === undefined) return []
|
|
153
|
+
try {
|
|
154
|
+
return jobs.list(caller).map((job: JobSnapshot) => ({
|
|
155
|
+
id: job.id,
|
|
156
|
+
kind: job.kind,
|
|
157
|
+
label: job.label,
|
|
158
|
+
status: job.status,
|
|
159
|
+
detail: job.detail,
|
|
160
|
+
startedAt: job.startedAt,
|
|
161
|
+
finishedAt: job.finishedAt,
|
|
162
|
+
}))
|
|
163
|
+
} catch {
|
|
164
|
+
return []
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve the working directory's git branch for the status line.
|
|
170
|
+
* @param cwd - the session's working directory.
|
|
171
|
+
* @returns the branch name, or '' outside a repository or on a detached HEAD.
|
|
172
|
+
*/
|
|
173
|
+
function gitBranch(cwd: string): string {
|
|
174
|
+
try {
|
|
175
|
+
const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
|
|
176
|
+
return ref?.[1] ?? ''
|
|
177
|
+
} catch {
|
|
178
|
+
// Only the single HEAD read is attempted, so the sole reachable failure is
|
|
179
|
+
// a missing repository (or unreadable HEAD file): the branch group drops out.
|
|
180
|
+
return ''
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The session identity this invocation will run, plus whether it is resumed. */
|
|
185
|
+
interface Target {
|
|
186
|
+
sessionId: string
|
|
187
|
+
resume: boolean
|
|
188
|
+
mode?: string
|
|
189
|
+
cwd?: string
|
|
190
|
+
seed?: readonly SessionEvent[]
|
|
191
|
+
parentSession?: SessionId
|
|
192
|
+
seedLength?: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
197
|
+
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
198
|
+
* user text: path separators must never leak into the default export filename
|
|
199
|
+
* (which would escape the session cwd).
|
|
200
|
+
* @param id - the session id.
|
|
201
|
+
* @returns at most the last 8 filename-safe characters.
|
|
202
|
+
*/
|
|
203
|
+
export function exportSessionIdSuffix(id: string): string {
|
|
204
|
+
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One ordered step of the terminal quit cleanup. */
|
|
208
|
+
export interface QuitCleanupStep {
|
|
209
|
+
/** Step label used in diagnostics and tests. */
|
|
210
|
+
readonly name: string
|
|
211
|
+
/** The step's async work; a rejection is contained by the sequence. */
|
|
212
|
+
readonly run: () => Promise<void>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run the ordered quit cleanup, then request exit. Every step rejection is
|
|
217
|
+
* contained (reported through `onError`) so a failed flush or dispose never
|
|
218
|
+
* skips the remaining cleanup; the exit request is always reached exactly
|
|
219
|
+
* once.
|
|
220
|
+
* @param steps - the cleanup steps in dependency order (settle the visible
|
|
221
|
+
* session, await the final in-flight composition, await durable recall).
|
|
222
|
+
* @param exit - the terminal exit request (code 0).
|
|
223
|
+
* @param onError - optional failure sink; called once per failing step and
|
|
224
|
+
* itself contained, so a throwing sink cannot abort the sequence.
|
|
225
|
+
* @returns the names of the steps that started, in order (for tests).
|
|
226
|
+
*/
|
|
227
|
+
export async function runQuitSequence(
|
|
228
|
+
steps: readonly QuitCleanupStep[],
|
|
229
|
+
exit: (code: number) => void,
|
|
230
|
+
onError?: (name: string, error: unknown) => void,
|
|
231
|
+
): Promise<readonly string[]> {
|
|
232
|
+
const started: string[] = []
|
|
233
|
+
for (const step of steps) {
|
|
234
|
+
started.push(step.name)
|
|
235
|
+
try {
|
|
236
|
+
await step.run()
|
|
237
|
+
} catch (error) {
|
|
238
|
+
try {
|
|
239
|
+
onError?.(step.name, error)
|
|
240
|
+
} catch {
|
|
241
|
+
// The failure sink must never abort the cleanup sequence.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
exit(0)
|
|
247
|
+
} catch {
|
|
248
|
+
// The exit request itself must not become an unhandled rejection.
|
|
249
|
+
}
|
|
250
|
+
return started
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One composer submission waiting behind the startup delivery. */
|
|
254
|
+
export interface QueuedSubmission {
|
|
255
|
+
readonly text: string
|
|
256
|
+
readonly mode: 'followup' | 'steer'
|
|
257
|
+
readonly images: readonly ContentBlock[]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether a tagged submission still belongs to the active session. Attachment
|
|
262
|
+
* prepares resolve on the microtask timeline, while a queued session switch
|
|
263
|
+
* remounts the app asynchronously — the composing instance's unmount cleanup
|
|
264
|
+
* runs too late to abort, so the delivery itself carries the composing
|
|
265
|
+
* session's full id and the runner drops it here when the world moved on.
|
|
266
|
+
* An untagged (synchronous) or pending-session ('') submission always passes.
|
|
267
|
+
*/
|
|
268
|
+
export function submissionBelongsToSession(origin: string | undefined, activeSessionId: string | undefined): boolean {
|
|
269
|
+
return origin === undefined || origin === '' || origin === activeSessionId
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One Shift+Tab station decision for the mode cycle. */
|
|
273
|
+
export type ModeCycleDecision =
|
|
274
|
+
| { readonly kind: 'permission'; readonly preset: string }
|
|
275
|
+
| { readonly kind: 'plan-on' }
|
|
276
|
+
| { readonly kind: 'plan-off'; readonly preset: string }
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Decide the next Shift+Tab station. The cycle keeps the preset table's
|
|
280
|
+
* own order (most restrictive first) and inserts ONE plan station between
|
|
281
|
+
* the most restrictive preset and the wrap target: with the shipped three
|
|
282
|
+
* presets the user sees workspace-write → danger-full-access → read-only
|
|
283
|
+
* → plan → workspace-write. Plan IS the most restrictive preset plus the
|
|
284
|
+
* plan prompt layer — entering it switches nothing (the cycle is already
|
|
285
|
+
* parked on read-only), and leaving it lands on the next preset after the
|
|
286
|
+
* most restrictive one. Without the /plan command the cycle is exactly the
|
|
287
|
+
* preset table.
|
|
288
|
+
*/
|
|
289
|
+
export function planCycleDecision(input: {
|
|
290
|
+
readonly names: readonly string[]
|
|
291
|
+
readonly current: string
|
|
292
|
+
readonly inPlan: boolean
|
|
293
|
+
readonly planAvailable: boolean
|
|
294
|
+
}): ModeCycleDecision | undefined {
|
|
295
|
+
const names = input.names
|
|
296
|
+
if (names.length === 0) return undefined
|
|
297
|
+
const first = names[0]!
|
|
298
|
+
if (input.inPlan) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
299
|
+
const at = names.indexOf(input.current)
|
|
300
|
+
if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
|
|
301
|
+
return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Order-preserving gate for composer input while the startup prompt/images
|
|
306
|
+
* are still preparing. Anything submitted before the startup delivery settles
|
|
307
|
+
* queues and flushes afterwards in submit order, so the initial request can
|
|
308
|
+
* never be overtaken by typing that raced a slow image preparation. The flush
|
|
309
|
+
* also runs when the startup delivery fails: user input is never stranded.
|
|
310
|
+
*/
|
|
311
|
+
export class StartupInputGate {
|
|
312
|
+
private readonly queued: QueuedSubmission[] = []
|
|
313
|
+
private pending = false
|
|
314
|
+
constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
|
|
315
|
+
|
|
316
|
+
/** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
|
|
317
|
+
submit(submission: QueuedSubmission): void {
|
|
318
|
+
if (this.pending) this.queued.push(submission)
|
|
319
|
+
else this.deliver(submission)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Run the startup delivery — the callback receives the direct-delivery sink
|
|
324
|
+
* for the startup prompt itself — then flush everything that queued behind
|
|
325
|
+
* it, in order, even when the callback rejects.
|
|
326
|
+
*/
|
|
327
|
+
async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
|
|
328
|
+
this.pending = true
|
|
329
|
+
try {
|
|
330
|
+
await startup(submission => this.deliver(submission))
|
|
331
|
+
} finally {
|
|
332
|
+
this.pending = false
|
|
333
|
+
const queued = this.queued.splice(0)
|
|
334
|
+
for (const submission of queued) this.deliver(submission)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the invocation's target session against the persisted headers.
|
|
341
|
+
* @param startup - the parsed startup flags.
|
|
342
|
+
* @param persistence - the persistence service; required for resume/latest.
|
|
343
|
+
* @param cwd - the working directory `--continue` filters by.
|
|
344
|
+
* @returns the target identity.
|
|
345
|
+
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
346
|
+
*/
|
|
347
|
+
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
348
|
+
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
349
|
+
if (startup.kind === 'named') {
|
|
350
|
+
// The id must not exist yet: reject before any Agent composition when the
|
|
351
|
+
// backend can tell us (a live collision is still caught by the session
|
|
352
|
+
// store at create time).
|
|
353
|
+
if (persistence !== undefined) {
|
|
354
|
+
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
355
|
+
if (headers.some(header => header.id === startup.sessionId)) {
|
|
356
|
+
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
360
|
+
}
|
|
361
|
+
if (persistence === undefined) {
|
|
362
|
+
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
363
|
+
}
|
|
364
|
+
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
365
|
+
if (startup.kind === 'resume') {
|
|
366
|
+
const matched = matchSessionId(headers, startup.sessionId)
|
|
367
|
+
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
368
|
+
// be a back door into appending root turns to a child's durable log.
|
|
369
|
+
if (isSubagentSession(matched)) {
|
|
370
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
371
|
+
}
|
|
372
|
+
return { sessionId: matched.id, resume: true }
|
|
373
|
+
}
|
|
374
|
+
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
375
|
+
const newest = newestRootForCwd(headers, cwd)
|
|
376
|
+
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
377
|
+
return { sessionId: newest.id, resume: true }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Resolve a bounded command preview for one pending approval: the request
|
|
382
|
+
* contract carries no arguments, so the bar self-serves from the transcript
|
|
383
|
+
* projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
|
|
384
|
+
* @param events - the transcript entries to search.
|
|
385
|
+
* @param callId - the tool call the question is about, when the asker had one.
|
|
386
|
+
* @param toolName - the tool the question is about.
|
|
387
|
+
* @returns a bounded preview line, '' when nothing useful resolves.
|
|
388
|
+
*/
|
|
389
|
+
function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
|
|
390
|
+
if (callId === undefined) return ''
|
|
391
|
+
const entry = events.find(candidate =>
|
|
392
|
+
candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
|
|
393
|
+
if (entry === undefined) return ''
|
|
394
|
+
const args = (entry as { arguments?: string }).arguments ?? ''
|
|
395
|
+
return toolArgumentsPreview(args, toolName)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The runner's connection between the React app and the process side. */
|
|
399
|
+
interface AppBridge {
|
|
400
|
+
/** Post one local notice line (feedback the transcript does not carry). */
|
|
401
|
+
notify(text: string, tone?: NoticeTone): void
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Run the interactive terminal session: resolve the target session, create or
|
|
406
|
+
* resume one Agent, mount the app, and keep the process alive until the user
|
|
407
|
+
* quits.
|
|
408
|
+
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
409
|
+
* @param startup - the parsed invocation flags.
|
|
410
|
+
* @param io - process-facing effects.
|
|
411
|
+
*/
|
|
412
|
+
async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
|
|
413
|
+
// Loader siblings mount concurrently. Await the complete application before
|
|
414
|
+
// creating an Agent so its scoped tools and adapters are not half-composed.
|
|
415
|
+
await ctx.get('loader')?.await()
|
|
416
|
+
const agents = ctx.get('agents')
|
|
417
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
418
|
+
const sessions = ctx.get('sessions')
|
|
419
|
+
const persistence = ctx.get('sessionPersistence')
|
|
420
|
+
const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
|
|
421
|
+
// Early process shutdown can dispose the tree while settlement is pending.
|
|
422
|
+
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
423
|
+
|
|
424
|
+
const cwd = process.cwd()
|
|
425
|
+
// Live deployment default (web selectModel parity): read on every use, not
|
|
426
|
+
// snapshotted at launch, so a /model pick this process saves becomes the
|
|
427
|
+
// default for sessions composed afterwards without a restart.
|
|
428
|
+
const currentDefaults = (): ModelSelection => defaultModel.currentSelection()
|
|
429
|
+
const presets = agentPresetsFrom(ctx)
|
|
430
|
+
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
431
|
+
const permissionPresets = permissionPresetsFrom(ctx)
|
|
432
|
+
|
|
433
|
+
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
434
|
+
// nothing is persisted, until the user's first real input. Explicit flags
|
|
435
|
+
// (--resume/--continue/--session/--mode) keep the eager create/resume path.
|
|
436
|
+
const lazy = startup.kind === 'fresh' && startup.mode === undefined
|
|
437
|
+
|
|
438
|
+
interface ActiveSession {
|
|
439
|
+
handle: AgentHandle
|
|
440
|
+
agent: Agent
|
|
441
|
+
session: Session
|
|
442
|
+
store: ReturnType<typeof createTranscriptStore>
|
|
443
|
+
mentions: MentionsApi
|
|
444
|
+
mode: string
|
|
445
|
+
selection: { picked?: ModelSelection }
|
|
446
|
+
resumed: boolean
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
450
|
+
const prepare = async (next: Target): Promise<ActiveSession> => {
|
|
451
|
+
const nextCwd = next.cwd ?? cwd
|
|
452
|
+
// A bare launch can pick a model before any session exists: the process
|
|
453
|
+
// keeps that explicit choice and every prepared session starts from it
|
|
454
|
+
// (the documented precedence: explicit pick > session header > default).
|
|
455
|
+
const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
|
|
456
|
+
? {}
|
|
457
|
+
: { picked: pendingSelection }
|
|
458
|
+
let mode = next.resume ? next.mode : next.mode ?? pendingMode
|
|
459
|
+
// An explicit `--mode` or the settings-layer service default may still name
|
|
460
|
+
// an id an upstream rename retired (code → ptc); normalize both.
|
|
461
|
+
if (!next.resume) mode = (await presets.resolve(normalizePresetId(mode ?? presets.defaultId))).id
|
|
462
|
+
// 0.1.5 AgentSetup passes the composed agent as its second argument (the
|
|
463
|
+
// former `ctx.agent` accessor is gone); the preset mount still needs the
|
|
464
|
+
// agent-scoped context.
|
|
465
|
+
const setup = async (agentCtx: Context, agent: Agent): Promise<void> => {
|
|
466
|
+
const sessionPreset = next.resume
|
|
467
|
+
? resolvePreset(agent.session)
|
|
468
|
+
: mode
|
|
469
|
+
const mounted = await presets.mount(agentCtx, sessionPreset)
|
|
470
|
+
mode = mounted.id
|
|
471
|
+
const selection: ModelSelectionRef = {
|
|
472
|
+
get current(): ModelSelection | undefined {
|
|
473
|
+
return resolveEffectiveSelection(selectionState.picked, agent.session.requestHeader()?.config, currentDefaults())
|
|
474
|
+
},
|
|
475
|
+
set current(value: ModelSelection | undefined) { selectionState.picked = value },
|
|
476
|
+
assembled: undefined,
|
|
477
|
+
}
|
|
478
|
+
installModelSelection(agentCtx, selection)
|
|
479
|
+
}
|
|
480
|
+
// AgentOptions seed the loop's fallback route; effort rides the selection
|
|
481
|
+
// ref (installModelSelection), so only the provider/model pair is seeded.
|
|
482
|
+
const seedOptions = pendingSelection === undefined
|
|
483
|
+
? { provider: currentDefaults().provider, model: currentDefaults().model }
|
|
484
|
+
: { provider: pendingSelection.provider, model: pendingSelection.model }
|
|
485
|
+
const handle = next.resume
|
|
486
|
+
? await agents.resume({
|
|
487
|
+
resumeSessionId: SessionId(next.sessionId),
|
|
488
|
+
agentOptions: seedOptions,
|
|
489
|
+
// Quit aborts an in-flight composition so the exit wait never hangs
|
|
490
|
+
// on a prepare that cannot settle; upstream rolls the creation back.
|
|
491
|
+
signal: quitAbort.signal,
|
|
492
|
+
setup,
|
|
493
|
+
})
|
|
494
|
+
: await agents.create({
|
|
495
|
+
sessionId: SessionId(next.sessionId),
|
|
496
|
+
meta: {
|
|
497
|
+
cwd: nextCwd,
|
|
498
|
+
agentPreset: mode,
|
|
499
|
+
...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
|
|
500
|
+
// 0.1.5 fork lineage: the seed marker lives on the metadata and the
|
|
501
|
+
// inherited prefix length on the top-level option (the v0 header's
|
|
502
|
+
// numeric `seedLength` field is gone from the create contract).
|
|
503
|
+
...(next.seedLength === undefined ? {} : { isSeeded: true }),
|
|
504
|
+
},
|
|
505
|
+
...(next.seedLength === undefined ? {} : { inheritedEventCount: SessionLogOffset(next.seedLength) }),
|
|
506
|
+
...(next.seed === undefined ? {} : { seed: next.seed }),
|
|
507
|
+
agentOptions: seedOptions,
|
|
508
|
+
signal: quitAbort.signal,
|
|
509
|
+
setup,
|
|
510
|
+
})
|
|
511
|
+
const session = handle.agent.session
|
|
512
|
+
if (!next.resume && permissionPresets !== undefined) {
|
|
513
|
+
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
handle,
|
|
517
|
+
agent: handle.agent,
|
|
518
|
+
session,
|
|
519
|
+
store: createTranscriptStore(session.snapshotEvents()),
|
|
520
|
+
mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
|
|
521
|
+
mode: mode ?? 'standard',
|
|
522
|
+
selection: selectionState,
|
|
523
|
+
resumed: next.resume,
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let active: ActiveSession | undefined
|
|
528
|
+
let agent: Agent | undefined
|
|
529
|
+
let session: Session | undefined
|
|
530
|
+
let store: TranscriptStore = createTranscriptStore()
|
|
531
|
+
// Live subagent activity (child sessions of the current root): one bounded
|
|
532
|
+
// row per child, folded from the same event bus the transcript feeds on.
|
|
533
|
+
const subagents: SubagentFeedView & { apply(sessionId: string, event: SessionEvent): void; reset(): void } = createSubagentFeed()
|
|
534
|
+
// Pre-session @file completion runs the official search over the launch
|
|
535
|
+
// cwd (model- and session-independent); the prepare/activate paths replace
|
|
536
|
+
// this with the agent-scoped instance once a session exists.
|
|
537
|
+
let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
|
|
538
|
+
/** Explicit model pick made before any session exists (a bare launch). */
|
|
539
|
+
let pendingSelection: ModelSelection | undefined
|
|
540
|
+
/** Agent preset selected before the first session exists. */
|
|
541
|
+
let pendingMode: string | undefined
|
|
542
|
+
/** Ordered pre-session preset resolutions; first composition awaits them. */
|
|
543
|
+
let pendingModeWork: Promise<void> = Promise.resolve()
|
|
544
|
+
/** Permission preset selected before the first session exists. */
|
|
545
|
+
let pendingPermission: string | undefined
|
|
546
|
+
/**
|
|
547
|
+
* Plan-mode choice made before the first session exists: materialized as a
|
|
548
|
+
* /plan registry command delivered ahead of the first queued input when the
|
|
549
|
+
* session composes, so the first assembled step already plans.
|
|
550
|
+
*/
|
|
551
|
+
let pendingPlan = false
|
|
552
|
+
/**
|
|
553
|
+
* Whether the pre-session effective preset composes plan mode, answered by
|
|
554
|
+
* the presets service composition inventory (minimal does not). Cached and
|
|
555
|
+
* refreshed whenever the pending mode moves; unknown reads as unavailable
|
|
556
|
+
* so one keypress at most lands before the answer arrives.
|
|
557
|
+
*/
|
|
558
|
+
let preSessionPlanAvailable = false
|
|
559
|
+
let preSessionPlanKnown = false
|
|
560
|
+
const refreshPreSessionPlan = (): void => {
|
|
561
|
+
if (presets === undefined) {
|
|
562
|
+
preSessionPlanAvailable = false
|
|
563
|
+
preSessionPlanKnown = true
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
preSessionPlanKnown = false
|
|
567
|
+
void presets.compositionInventory().then(inventory => {
|
|
568
|
+
const id = pendingMode ?? normalizePresetId(presets.defaultId)
|
|
569
|
+
preSessionPlanAvailable = inventory.some(composition => composition.id === id
|
|
570
|
+
&& composition.rows.some(row => row.moduleName === '@deepseek-ai/dsh-plan-mode' && row.enabled !== false))
|
|
571
|
+
preSessionPlanKnown = true
|
|
572
|
+
}, () => {
|
|
573
|
+
preSessionPlanAvailable = false
|
|
574
|
+
preSessionPlanKnown = true
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
refreshPreSessionPlan()
|
|
578
|
+
/**
|
|
579
|
+
* Monotonic session epoch: bumped on every successful activation, on every
|
|
580
|
+
* first-session creation, and on quit. Async callbacks (mention prepares,
|
|
581
|
+
* command executions) capture it at call time and drop their result when it
|
|
582
|
+
* changed, so a stale callback can never deliver to an agent that is no
|
|
583
|
+
* longer on screen.
|
|
584
|
+
*/
|
|
585
|
+
let epoch = 0
|
|
586
|
+
/** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
|
|
587
|
+
const quitAbort = new AbortController()
|
|
588
|
+
/** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
|
|
589
|
+
const pendingControllers = new Set<AbortController>()
|
|
590
|
+
const abortPendingControllers = (): void => {
|
|
591
|
+
for (const controller of [...pendingControllers]) {
|
|
592
|
+
pendingControllers.delete(controller)
|
|
593
|
+
controller.abort()
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/** The in-flight session-composition turn (create/resume/activate), if any. */
|
|
597
|
+
let composing: Promise<void> | undefined
|
|
598
|
+
/**
|
|
599
|
+
* Run one session composition exclusively: concurrent compositions wait
|
|
600
|
+
* their turn, so a bare-launch first-session creation and a /resume
|
|
601
|
+
* activation can never compose agents in parallel (the loser would leak its
|
|
602
|
+
* agent or mis-deliver). Errors propagate to the caller; the shared slot
|
|
603
|
+
* always continues.
|
|
604
|
+
*/
|
|
605
|
+
const compose = (work: () => Promise<void>): Promise<void> => {
|
|
606
|
+
const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
|
|
607
|
+
composing = turn.catch(() => {})
|
|
608
|
+
return turn
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (!lazy) {
|
|
612
|
+
const target = await resolveTarget(startup, persistence, cwd)
|
|
613
|
+
const prepared = await prepare(target)
|
|
614
|
+
active = prepared
|
|
615
|
+
agent = prepared.agent
|
|
616
|
+
session = prepared.session
|
|
617
|
+
store = prepared.store
|
|
618
|
+
mentions = prepared.mentions
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Seed the transcript from the full session log: constructor seeds never
|
|
622
|
+
// fire on `session/event`, so a resumed session paints its history once
|
|
623
|
+
// before the first render. The handler reads the current session/store, so
|
|
624
|
+
// the deferred first session of a bare launch is covered by the same feed.
|
|
625
|
+
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
626
|
+
if (session === undefined) return
|
|
627
|
+
if (subject.id === session.id) {
|
|
628
|
+
store.apply(event)
|
|
629
|
+
// The parent-owned subagent catalog rides the ROOT log (0.1.5); each
|
|
630
|
+
// fact describes one child, so it feeds that child's live row.
|
|
631
|
+
if (event.type === 'subagent/catalog' && event.data.childId !== '') subagents.apply(event.data.childId, event)
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
// Child sessions (subagent conversations this root spawned) fold into
|
|
635
|
+
// the bounded live-activity feed, never the transcript: the root stays
|
|
636
|
+
// the only durable transcript truth while a running subagent remains
|
|
637
|
+
// visible. Lineage comes from the child header, same field the session
|
|
638
|
+
// directory uses to tag `↳` rows.
|
|
639
|
+
if (subject.header.parentSession === session.id && subject.header.origin === 'subagent') subagents.apply(subject.id, event)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
// Live assistant typing (session-log v2+): durable logs are settlement-only,
|
|
643
|
+
// so the streaming tails ride the process-local `agent/assistant-stream`
|
|
644
|
+
// frames of the current root agent. Settlement events clear the tails when
|
|
645
|
+
// they land (always before a committed end frame); an abandoned attempt's
|
|
646
|
+
// partial tail is dropped by the store on its end frame.
|
|
647
|
+
ctx.on('agent/assistant-stream', ({ agent: source, frame }) => {
|
|
648
|
+
if (agent === undefined || source.id !== agent.id) return
|
|
649
|
+
store.applyStreamFrame(frame)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
const commands: CommandsView = watchCommands(ctx)
|
|
653
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
654
|
+
|
|
655
|
+
const skills: SkillsView = watchSkills(ctx, cwd)
|
|
656
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
657
|
+
|
|
658
|
+
// Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
|
|
659
|
+
// claimed, every other ask falls through to the fail-closed waterfall. The
|
|
660
|
+
// owner predicate is empty until the first session exists.
|
|
661
|
+
const approval: ApprovalStore = mountApprovalAnswerer(
|
|
662
|
+
ctx,
|
|
663
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
664
|
+
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
// Subagent model routing. The kernel seeds child agents from the parent's
|
|
668
|
+
// CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
|
|
669
|
+
// /model switch never touches — delegated work would keep running on the
|
|
670
|
+
// launch-time route. This plugin-level listener mirrors installModelSelection
|
|
671
|
+
// for subagent-origin requests (scope filtering delivers the agent subject
|
|
672
|
+
// inside the payload): the explicit /subagent override wins, else the root's
|
|
673
|
+
// effective selection (explicit pick > session header > deployment default).
|
|
674
|
+
// Effort rides the selection exactly like the kernel listener applies it.
|
|
675
|
+
let subagentOverride: ModelSelection | undefined
|
|
676
|
+
ctx.on('agent/request', (payload, next) => {
|
|
677
|
+
const subject = payload.agent
|
|
678
|
+
const header = subject.session.header
|
|
679
|
+
if (header.parentSession === undefined && header.origin !== 'subagent') return next()
|
|
680
|
+
// Only the ACTIVE session's explicit pick may steer a subagent request.
|
|
681
|
+
// During a switch window the old agent can still be mid-flight; routing
|
|
682
|
+
// it by the NEW session's pick sent one of its requests to the wrong
|
|
683
|
+
// model. A subject outside the active tree falls back to its own request
|
|
684
|
+
// header (plus any explicit /subagent override, which is user intent).
|
|
685
|
+
const activeAgent = active
|
|
686
|
+
const belongsToActive = activeAgent !== undefined
|
|
687
|
+
&& (header.parentSession ?? subject.session.id) === activeAgent.session.id
|
|
688
|
+
const picked = subagentOverride
|
|
689
|
+
?? resolveEffectiveSelection(
|
|
690
|
+
belongsToActive && activeAgent !== undefined ? (activeAgent.selection.picked ?? pendingSelection) : undefined,
|
|
691
|
+
subject.session.requestHeader()?.config,
|
|
692
|
+
currentDefaults(),
|
|
693
|
+
)
|
|
694
|
+
return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// ask_user_question answerer: one waterfall listener, one request on
|
|
698
|
+
// screen at a time. Plan reviews (exit_plan_mode) arrive through this same
|
|
699
|
+
// pipe; sibling answerers stay usable through the claim/defer split.
|
|
700
|
+
const questions: QuestionStore = mountQuestionProvider(
|
|
701
|
+
ctx,
|
|
702
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
// The bridge the React app registers on mount: local notices from the
|
|
706
|
+
// process side (unknown commands, switch confirmations, cancels).
|
|
707
|
+
const bridge: AppBridge = { notify: () => {} }
|
|
708
|
+
|
|
709
|
+
// Same-id capability inheritance. Catalog capabilities flow by route key,
|
|
710
|
+
// not model id, so a hand-declared relay model without an explicit
|
|
711
|
+
// reasoningEfforts declaration serves no reasoning levels and offers no
|
|
712
|
+
// effort picker. This background pass materializes declarations from
|
|
713
|
+
// same-id donors (sibling settings entries first, then other routes'
|
|
714
|
+
// advertised levels) over the panel's settings.mutate path, where the
|
|
715
|
+
// upstream serviceability gate still rejects invalid writes atomically.
|
|
716
|
+
// The debounce coalesces the settings/adapters event pair; the applier
|
|
717
|
+
// skips only a same-source same-revision echo of its own write, so the
|
|
718
|
+
// loop converges without ever ignoring a real external edit.
|
|
719
|
+
const capabilitySyncDebounceMs = 400
|
|
720
|
+
const runCapabilitySync = (): void => {
|
|
721
|
+
void syncModelCapabilities(ctx, bridge.notify)
|
|
722
|
+
}
|
|
723
|
+
let capabilitySyncTimer: ReturnType<typeof setTimeout> | undefined
|
|
724
|
+
const scheduleCapabilitySync = (): void => {
|
|
725
|
+
if (capabilitySyncTimer !== undefined) clearTimeout(capabilitySyncTimer)
|
|
726
|
+
capabilitySyncTimer = setTimeout(() => {
|
|
727
|
+
capabilitySyncTimer = undefined
|
|
728
|
+
runCapabilitySync()
|
|
729
|
+
}, capabilitySyncDebounceMs)
|
|
730
|
+
}
|
|
731
|
+
const offCapabilitySync = [
|
|
732
|
+
ctx.on('settings/document-updated', scheduleCapabilitySync),
|
|
733
|
+
ctx.on('llm/adapters-updated', scheduleCapabilitySync),
|
|
734
|
+
]
|
|
735
|
+
scheduleCapabilitySync()
|
|
736
|
+
|
|
737
|
+
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
738
|
+
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
739
|
+
// surfaced warning (the customization is user-authored, never silent).
|
|
740
|
+
const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
|
|
741
|
+
let statuslineWarning: string | undefined
|
|
742
|
+
let statuslineItems: readonly string[] = []
|
|
743
|
+
try {
|
|
744
|
+
statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
|
|
745
|
+
} catch (error) {
|
|
746
|
+
statuslineItems = parseStatuslineItems(undefined)
|
|
747
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
748
|
+
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Serialized, crash-atomic writes for the user-level JSON files: the chain
|
|
752
|
+
// orders rapid consecutive saves (the LAST snapshot wins on disk), each
|
|
753
|
+
// write goes through a sibling temp file + rename, and quit waits for the
|
|
754
|
+
// flush exactly like it waits for the recall history.
|
|
755
|
+
const settingsPersistence = createUserSettingsPersistence()
|
|
756
|
+
const saveStatusline = (items: readonly string[]): void => {
|
|
757
|
+
statuslineItems = [...items]
|
|
758
|
+
void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
|
|
759
|
+
.catch((writeError: unknown) => {
|
|
760
|
+
bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// /vscode-keys: detect the hosting editor's user keybindings.json and pass
|
|
765
|
+
// Ctrl+R through the workbench. One marker file under the DSH home keeps
|
|
766
|
+
// the startup hint a once-per-install event.
|
|
767
|
+
const editorKeysEnv: EditorKeysEnv = {
|
|
768
|
+
env: process.env,
|
|
769
|
+
paths: { homedir: homedir(), appdata: process.env.APPDATA, platform: process.platform },
|
|
770
|
+
flagPath: join(homedir(), '.dsh', 'dsh-code', 'editor-keys.json'),
|
|
771
|
+
}
|
|
772
|
+
const applyEditorKeys = (): Promise<string> => applyCtrlRPassthrough(editorKeysEnv)
|
|
773
|
+
|
|
774
|
+
// /theme persistence: one user-level JSON file under the DSH home, mirroring
|
|
775
|
+
// the statusline file. A missing file means the dark default; a corrupt file
|
|
776
|
+
// degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
|
|
777
|
+
// auto detection > dark (auto detection itself is a later enhancement and
|
|
778
|
+
// currently falls back to dark inside theme.ts).
|
|
779
|
+
const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json')
|
|
780
|
+
let themeWarning: string | undefined
|
|
781
|
+
if (startup.theme === undefined) {
|
|
782
|
+
try {
|
|
783
|
+
setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
786
|
+
themeWarning = error instanceof Error ? error.message : String(error)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} else {
|
|
790
|
+
setTheme(startup.theme)
|
|
791
|
+
}
|
|
792
|
+
const saveTheme = (name: ThemeName): void => {
|
|
793
|
+
setTheme(name)
|
|
794
|
+
void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
|
|
795
|
+
.catch((writeError: unknown) => {
|
|
796
|
+
bridge.notify('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
797
|
+
})
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// /animation persistence: one user-level JSON file under the DSH home,
|
|
801
|
+
// mirroring the theme file. A missing file means animations are on; a
|
|
802
|
+
// corrupt file degrades to on with a surfaced warning. Only an explicit
|
|
803
|
+
// `false` disables (parseAnimationsPref), so hand-edited or partial files
|
|
804
|
+
// never silently freeze the UI.
|
|
805
|
+
const animationsPath = join(homedir(), '.dsh', 'dsh-code', 'animations.json')
|
|
806
|
+
let animationsEnabled = true
|
|
807
|
+
let animationsWarning: string | undefined
|
|
808
|
+
try {
|
|
809
|
+
// `?? {}` keeps a literal `null` file from surfacing a cryptic TypeError.
|
|
810
|
+
animationsEnabled = parseAnimationsPref((JSON.parse(readFileSync(animationsPath, 'utf8')) ?? {}).animations)
|
|
811
|
+
} catch (error) {
|
|
812
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
813
|
+
animationsWarning = error instanceof Error ? error.message : String(error)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const saveAnimations = (enabled: boolean): void => {
|
|
817
|
+
void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
|
|
818
|
+
.catch((writeError: unknown) => {
|
|
819
|
+
bridge.notify('animations save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Global input recall (Codex composer-history contract): one JSONL file
|
|
824
|
+
// under the DSH home. A missing file means an empty history; unreadable or
|
|
825
|
+
// corrupt content degrades to the valid lines it could parse, silently —
|
|
826
|
+
// recall is a convenience surface, never a gate.
|
|
827
|
+
const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
|
|
828
|
+
let inputHistory: readonly string[] = []
|
|
829
|
+
let historyWriteChain: Promise<void> = Promise.resolve()
|
|
830
|
+
try {
|
|
831
|
+
const rawHistory = readFileSync(historyPath, 'utf8')
|
|
832
|
+
inputHistory = parseHistoryFile(rawHistory)
|
|
833
|
+
// Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
|
|
834
|
+
// accumulate in an append-only file; rewrite the canonical form once
|
|
835
|
+
// per boot. The rewrite rides the same chain, so it lands before any
|
|
836
|
+
// submission the user types next. An entry another terminal appends
|
|
837
|
+
// inside the read-to-rename window is dropped — a millisecond-scale
|
|
838
|
+
// gap at boot that recall tolerates by design.
|
|
839
|
+
if (needsCompaction(rawHistory)) {
|
|
840
|
+
historyWriteChain = historyWriteChain
|
|
841
|
+
.then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
|
|
842
|
+
.catch(() => {})
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
inputHistory = []
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Serialized history writes: each submission appends one JSON line at the
|
|
849
|
+
* end of the file, so concurrent terminals add entries after each other
|
|
850
|
+
* instead of overwriting snapshots they read at their own boot. A
|
|
851
|
+
* multi-line draft still occupies one physical line (JSON escapes the
|
|
852
|
+
* newline), and a regular-length line reaches the disk as one positioned
|
|
853
|
+
* write; an oversized paste may interleave mid-line, which the next
|
|
854
|
+
* parse simply drops.
|
|
855
|
+
*/
|
|
856
|
+
const recordHistory = (text: string): void => {
|
|
857
|
+
if (text === '') return
|
|
858
|
+
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
859
|
+
historyWriteChain = historyWriteChain
|
|
860
|
+
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
861
|
+
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
862
|
+
.catch((writeError: unknown) => {
|
|
863
|
+
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
|
|
868
|
+
const cancelQueued = (messageId: string): void => {
|
|
869
|
+
if (agent === undefined) return
|
|
870
|
+
try {
|
|
871
|
+
if (agent.inbox.remove(MessageId(messageId))) {
|
|
872
|
+
bridge.notify('queued message cancelled')
|
|
873
|
+
}
|
|
874
|
+
} catch (error: unknown) {
|
|
875
|
+
bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// The mount handle lives in a box: quit closes over it, while the mount
|
|
880
|
+
// itself is created after quit (the App element needs quit as a prop).
|
|
881
|
+
const mountRef: { current?: TuiMount } = {}
|
|
882
|
+
let quitting = false
|
|
883
|
+
const quit = (): void => {
|
|
884
|
+
if (quitting) return
|
|
885
|
+
quitting = true
|
|
886
|
+
switchQueue.cancel()
|
|
887
|
+
// Stale prepares/commands die with the session they were for. Aborting
|
|
888
|
+
// the composition signal lets a never-settling prepare reject, so the
|
|
889
|
+
// exit wait below cannot hang (upstream rolls the creation back).
|
|
890
|
+
abortPendingControllers()
|
|
891
|
+
quitAbort.abort()
|
|
892
|
+
epoch += 1
|
|
893
|
+
off()
|
|
894
|
+
for (const dispose of offCapabilitySync) dispose()
|
|
895
|
+
if (capabilitySyncTimer !== undefined) clearTimeout(capabilitySyncTimer)
|
|
896
|
+
const currentSession = session
|
|
897
|
+
const currentActive = active
|
|
898
|
+
const report = (name: string, error: unknown): void => {
|
|
899
|
+
internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
900
|
+
}
|
|
901
|
+
// A throwing unmount must not strand the terminal (stdin tap alive,
|
|
902
|
+
// keyboard protocol stacks unpopped) or skip the exit sequence below.
|
|
903
|
+
try {
|
|
904
|
+
mountRef.current?.unmount()
|
|
905
|
+
} catch (error: unknown) {
|
|
906
|
+
report('unmount', error)
|
|
907
|
+
}
|
|
908
|
+
// One ordered cleanup: settle the visible session (if any — a bare launch
|
|
909
|
+
// that never composed one resolves immediately), then wait for the final
|
|
910
|
+
// in-flight composition (its work swallows errors and the quitting guard
|
|
911
|
+
// disposes any half-prepared agent), then flush the durable recall and
|
|
912
|
+
// the queued user-level settings writes, then request exit. `composing`
|
|
913
|
+
// and `historyWriteChain` are read at step run
|
|
914
|
+
// time, so a turn that was still being queued when quit ran is included.
|
|
915
|
+
// A failing step must never skip the remaining cleanup.
|
|
916
|
+
const steps: QuitCleanupStep[] = [
|
|
917
|
+
...(currentSession === undefined || currentActive === undefined
|
|
918
|
+
? []
|
|
919
|
+
: [
|
|
920
|
+
{ name: 'flush', run: async () => { await sessions.flush(currentSession) } },
|
|
921
|
+
{ name: 'dispose', run: () => currentActive.handle.dispose() },
|
|
922
|
+
]),
|
|
923
|
+
{ name: 'composing', run: () => composing ?? Promise.resolve() },
|
|
924
|
+
{ name: 'history', run: () => historyWriteChain },
|
|
925
|
+
{ name: 'settings', run: () => settingsPersistence.flush() },
|
|
926
|
+
]
|
|
927
|
+
void runQuitSequence(steps, io.exit, report)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Run one slash line through the command registry (closed namespace). */
|
|
931
|
+
const runSlash = (line: string): void => {
|
|
932
|
+
const currentAgent = agent
|
|
933
|
+
if (currentAgent === undefined) return
|
|
934
|
+
if (line.startsWith('/resume ')) {
|
|
935
|
+
requestResume(line.slice(8).trim())
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
const registry = ctx.get('commands')
|
|
939
|
+
if (registry === undefined) {
|
|
940
|
+
bridge.notify('no command registry is mounted in this composition', 'error')
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
const controller = new AbortController()
|
|
944
|
+
const atEpoch = epoch
|
|
945
|
+
pendingControllers.add(controller)
|
|
946
|
+
const finish = (): void => {
|
|
947
|
+
pendingControllers.delete(controller)
|
|
948
|
+
}
|
|
949
|
+
// 0.1.5 registry.execute's third parameter admits submitted attachments
|
|
950
|
+
// (images and file receipts); the TUI composer never attaches images to a
|
|
951
|
+
// slash line, so every invocation is the empty batch (commands declaring
|
|
952
|
+
// input.attachments still run attachment-free).
|
|
953
|
+
void Promise.resolve().then(() => registry.execute(currentAgent, line, [], controller.signal)).then((execution) => {
|
|
954
|
+
finish()
|
|
955
|
+
// A switch/quit landed while the command ran: its fall-through must not
|
|
956
|
+
// reach an agent that is no longer on screen.
|
|
957
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
958
|
+
if (execution === undefined) {
|
|
959
|
+
// No command owns this line: send it verbatim so a user-invocable
|
|
960
|
+
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
961
|
+
// pre-step injection — the web composer's same fall-through.
|
|
962
|
+
try {
|
|
963
|
+
currentAgent.followup(createUserMessage({
|
|
964
|
+
content: [{ type: 'text', text: line }],
|
|
965
|
+
source: { kind: 'user' },
|
|
966
|
+
}))
|
|
967
|
+
} catch (error: unknown) {
|
|
968
|
+
bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}, (error: unknown) => {
|
|
972
|
+
finish()
|
|
973
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
974
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Delivery serialization state: the chain's epoch pins it to one session. */
|
|
979
|
+
let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
|
|
980
|
+
|
|
981
|
+
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
982
|
+
const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ContentBlock[] = []): void => {
|
|
983
|
+
const currentAgent = agent!
|
|
984
|
+
const currentMentions = mentions!
|
|
985
|
+
// The command registry is a closed namespace: slash lines run out of
|
|
986
|
+
// band and never reach the model through this path (steering keeps the
|
|
987
|
+
// registry out of the inbox, so slash lines steer as literal text).
|
|
988
|
+
if (images.length === 0 && isSlashLine(line) && mode === 'followup') {
|
|
989
|
+
runSlash(line)
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
let parsed: ReturnType<MentionsApi['parse']>
|
|
993
|
+
try {
|
|
994
|
+
parsed = currentMentions.parse(line)
|
|
995
|
+
} catch (error: unknown) {
|
|
996
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
// Ordered delivery: the inbox order IS the user's message order. A line
|
|
1000
|
+
// with session mentions prepares asynchronously, and a later plain line
|
|
1001
|
+
// used to deliver synchronously past it. Every line now waits for the
|
|
1002
|
+
// previous line of the same session; an epoch change (switch/quit)
|
|
1003
|
+
// abandons the chain instead of gating the next session on the old one.
|
|
1004
|
+
if (deliveryChain.epoch !== epoch) deliveryChain = { epoch, tail: Promise.resolve() }
|
|
1005
|
+
const enqueueDelivery = (run: () => void): void => {
|
|
1006
|
+
deliveryChain.tail = deliveryChain.tail.then(run)
|
|
1007
|
+
}
|
|
1008
|
+
const atEpoch = epoch
|
|
1009
|
+
const deliver = (readable: string, context?: UserMessage): void => {
|
|
1010
|
+
// A switch/quit landed while the snapshot was being prepared: never
|
|
1011
|
+
// deliver to an agent that is no longer on screen.
|
|
1012
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
1013
|
+
// Session snapshots ride the inbox as model-facing context ahead of
|
|
1014
|
+
// the readable message (upstream README wiring: inject before the
|
|
1015
|
+
// followup/steer that wakes the driver).
|
|
1016
|
+
try {
|
|
1017
|
+
if (context !== undefined) currentAgent.inject(context)
|
|
1018
|
+
const content: ContentBlock[] = [
|
|
1019
|
+
...(readable === '' ? [] : [{ type: 'text' as const, text: readable }]),
|
|
1020
|
+
...images,
|
|
1021
|
+
]
|
|
1022
|
+
const message = createUserMessage({
|
|
1023
|
+
content,
|
|
1024
|
+
source: { kind: 'user' },
|
|
1025
|
+
})
|
|
1026
|
+
if (mode === 'steer') {
|
|
1027
|
+
// The queued message is visible as a pending transcript row (the
|
|
1028
|
+
// web queue-mirror contract); no notice noise on the happy path.
|
|
1029
|
+
currentAgent.steer(message)
|
|
1030
|
+
} else {
|
|
1031
|
+
currentAgent.followup(message)
|
|
1032
|
+
}
|
|
1033
|
+
} catch (error: unknown) {
|
|
1034
|
+
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (parsed.references.length === 0) {
|
|
1038
|
+
enqueueDelivery(() => deliver(parsed.text))
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
const controller = new AbortController()
|
|
1042
|
+
pendingControllers.add(controller)
|
|
1043
|
+
enqueueDelivery(() => currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
1044
|
+
pendingControllers.delete(controller)
|
|
1045
|
+
deliver(prepared.text, prepared.additionalContext)
|
|
1046
|
+
}, (error: unknown) => {
|
|
1047
|
+
pendingControllers.delete(controller)
|
|
1048
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1049
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1050
|
+
}))
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Deferred first-session creation for a bare launch: the session is composed
|
|
1054
|
+
// only when the user submits real input (or /new), and every line that
|
|
1055
|
+
// arrives during creation is delivered in order afterwards. A creation
|
|
1056
|
+
// failure reports and clears the queue, leaving the transient state ready
|
|
1057
|
+
// for the next attempt.
|
|
1058
|
+
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer'; images: readonly ContentBlock[] }> = []
|
|
1059
|
+
// A creation is queued/running: further submissions must not mint more
|
|
1060
|
+
// fresh sessions (their lines queue into pendingInputs instead).
|
|
1061
|
+
let creating = false
|
|
1062
|
+
const ensureSession = (mode?: string): void => {
|
|
1063
|
+
if (creating) return
|
|
1064
|
+
creating = true
|
|
1065
|
+
void compose(async () => {
|
|
1066
|
+
try {
|
|
1067
|
+
// A direct `/mode <preset>` resolves asynchronously. Preserve submit
|
|
1068
|
+
// order so the first composition cannot race ahead with the old mode.
|
|
1069
|
+
await pendingModeWork
|
|
1070
|
+
// Another composition (e.g. a /resume activated while this creation
|
|
1071
|
+
// waited its turn) may have published a session already: deliver the
|
|
1072
|
+
// queued lines there instead of minting a competing fresh session
|
|
1073
|
+
// (which would orphan the live one without a dispose).
|
|
1074
|
+
if (session !== undefined) {
|
|
1075
|
+
const queued = pendingInputs.splice(0)
|
|
1076
|
+
for (const item of queued) deliverLine(item.text, item.mode, item.images)
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
const next = await prepare({
|
|
1080
|
+
sessionId: `session-${randomUUID()}`,
|
|
1081
|
+
resume: false,
|
|
1082
|
+
...(mode === undefined ? {} : { mode }),
|
|
1083
|
+
})
|
|
1084
|
+
if (quitting) {
|
|
1085
|
+
void next.handle.dispose().catch(() => {})
|
|
1086
|
+
return
|
|
1087
|
+
}
|
|
1088
|
+
const previous = { active, agent, session, store, mentions }
|
|
1089
|
+
try {
|
|
1090
|
+
active = next
|
|
1091
|
+
agent = next.agent
|
|
1092
|
+
session = next.session
|
|
1093
|
+
store = next.store
|
|
1094
|
+
mentions = next.mentions
|
|
1095
|
+
subagents.reset()
|
|
1096
|
+
pendingMode = undefined
|
|
1097
|
+
pendingPermission = undefined
|
|
1098
|
+
commands.setAgent(agent)
|
|
1099
|
+
skills.setAgent(agent)
|
|
1100
|
+
// The App mounts with a placeholder key until the first input; the
|
|
1101
|
+
// key-change remount below must start from a clean screen or the ghost
|
|
1102
|
+
// static header stays visible above the new one (same source-backed
|
|
1103
|
+
// clear the session-switch path performs).
|
|
1104
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1105
|
+
renderCurrent()
|
|
1106
|
+
} catch (error: unknown) {
|
|
1107
|
+
// The session composed but the screen handoff threw (stdout EPIPE,
|
|
1108
|
+
// a render-time failure). Roll the published state back exactly
|
|
1109
|
+
// like the switch path does — otherwise the runner reports "session
|
|
1110
|
+
// creation failed" while the new session is actually live, clears
|
|
1111
|
+
// the queued inputs, and every later line lands in the ghost. The
|
|
1112
|
+
// queued inputs are KEPT for the next attempt.
|
|
1113
|
+
active = previous.active
|
|
1114
|
+
agent = previous.agent
|
|
1115
|
+
session = previous.session
|
|
1116
|
+
store = previous.store === undefined ? createTranscriptStore() : previous.store
|
|
1117
|
+
mentions = previous.mentions === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1118
|
+
if (agent !== undefined) {
|
|
1119
|
+
commands.setAgent(agent)
|
|
1120
|
+
skills.setAgent(agent)
|
|
1121
|
+
}
|
|
1122
|
+
await next.handle.dispose().catch(() => {})
|
|
1123
|
+
if (!quitting) renderCurrent()
|
|
1124
|
+
bridge.notify(`session activation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
abortPendingControllers()
|
|
1128
|
+
epoch += 1
|
|
1129
|
+
const queued = pendingInputs.splice(0)
|
|
1130
|
+
if (pendingPlan) {
|
|
1131
|
+
pendingPlan = false
|
|
1132
|
+
// A pre-session plan choice materializes as the registry command
|
|
1133
|
+
// delivered AHEAD of the queued lines, so the first assembled step
|
|
1134
|
+
// of the user's opening message already runs in plan mode.
|
|
1135
|
+
deliverLine('/plan', 'followup')
|
|
1136
|
+
}
|
|
1137
|
+
for (const item of queued) deliverLine(item.text, item.mode, item.images)
|
|
1138
|
+
} finally {
|
|
1139
|
+
creating = false
|
|
1140
|
+
}
|
|
1141
|
+
}).catch((error: unknown) => {
|
|
1142
|
+
pendingInputs.length = 0
|
|
1143
|
+
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1144
|
+
})
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
1148
|
+
const sendNow = (text: string, mode: 'followup' | 'steer', images: readonly ContentBlock[] = []): void => {
|
|
1149
|
+
// Blank check on the trimmed form; the payload itself keeps the draft's
|
|
1150
|
+
// exact whitespace unless the line is a syntactic slash command.
|
|
1151
|
+
const line = submissionPayload(text)
|
|
1152
|
+
if (line.trim() === '' && images.length === 0) return
|
|
1153
|
+
if (images.length === 0 && line.startsWith('/mode ')) {
|
|
1154
|
+
void switchModeAction(line.slice(6).trim()).then(
|
|
1155
|
+
selected => bridge.notify(`mode → ${selected}`),
|
|
1156
|
+
error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
1157
|
+
)
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
if (images.length === 0 && line.startsWith('/permission ')) {
|
|
1161
|
+
try {
|
|
1162
|
+
const selected = setPermissionAction(line.slice(12).trim())
|
|
1163
|
+
bridge.notify(`permission → ${selected}`)
|
|
1164
|
+
} catch (error: unknown) {
|
|
1165
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1166
|
+
}
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
if (session === undefined) {
|
|
1170
|
+
pendingInputs.push({ text: line, mode, images })
|
|
1171
|
+
ensureSession()
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
deliverLine(line, mode, images)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Startup serialization: input submitted while the startup prompt/images
|
|
1178
|
+
// are still preparing queues behind the initial request.
|
|
1179
|
+
const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, mode, images))
|
|
1180
|
+
const send = (text: string, mode: 'followup' | 'steer', images: readonly ContentBlock[] = []): void => {
|
|
1181
|
+
inputGate.submit({ text, mode, images })
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
1185
|
+
const dispatch = (text: string, images: readonly ContentBlock[] = [], origin?: string): void => {
|
|
1186
|
+
// An attachment prepare resolved after the app remounted onto another
|
|
1187
|
+
// session (queued switch): the composing session is gone, so the stale
|
|
1188
|
+
// delivery is dropped instead of landing in the new session's inbox.
|
|
1189
|
+
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1190
|
+
send(text, 'followup', images)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Submit steering: a running driver consumes the text at its next step
|
|
1195
|
+
* boundary (the inbox delivers between steps); an idle driver just starts
|
|
1196
|
+
* a turn, so this doubles as the busy-state submit path.
|
|
1197
|
+
*/
|
|
1198
|
+
const steer = (text: string, images: readonly ContentBlock[] = [], origin?: string): void => {
|
|
1199
|
+
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1200
|
+
send(text, 'steer', images)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
1204
|
+
const interrupt = (): boolean => {
|
|
1205
|
+
if (agent === undefined || agent.status !== 'running') return false
|
|
1206
|
+
try {
|
|
1207
|
+
agent.cancel({ kind: 'user' })
|
|
1208
|
+
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
1209
|
+
return true
|
|
1210
|
+
} catch (error: unknown) {
|
|
1211
|
+
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1212
|
+
return false
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** Select one permission preset before the first session or on the active one. */
|
|
1217
|
+
const setPermissionAction = (id: string): string => {
|
|
1218
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
1219
|
+
throw new Error('permission presets are not mounted in this composition')
|
|
1220
|
+
}
|
|
1221
|
+
if (id === '') throw new Error('usage: /permission <preset>')
|
|
1222
|
+
const selected = selectPermission(permissionPresets, session, id)
|
|
1223
|
+
if (session === undefined) {
|
|
1224
|
+
pendingPermission = selected
|
|
1225
|
+
renderCurrent()
|
|
1226
|
+
}
|
|
1227
|
+
return selected
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Shift+Tab mode cycle: permission presets in table order, then the plan
|
|
1232
|
+
* station when the composition offers the /plan command (preset-mounted,
|
|
1233
|
+
* so minimal sessions and the pre-session state cycle permissions only).
|
|
1234
|
+
* Plan transitions submit the upstream registry command — it stays the
|
|
1235
|
+
* single owner of plan state; the TUI renders the durable plan/mode event
|
|
1236
|
+
* it appends. Returns the notice label, or '' when nothing changed.
|
|
1237
|
+
*/
|
|
1238
|
+
const cycleMode = (): string => {
|
|
1239
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
1240
|
+
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
1241
|
+
return ''
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
// Pre-session the plan station rides the pending choice; once a
|
|
1245
|
+
// session exists the scoped /plan command descriptor decides, and the
|
|
1246
|
+
// durable plan/mode event is the live truth.
|
|
1247
|
+
const preSession = session === undefined
|
|
1248
|
+
if (preSession && !preSessionPlanKnown) refreshPreSessionPlan()
|
|
1249
|
+
const decision = planCycleDecision({
|
|
1250
|
+
names: permissionPresets.names,
|
|
1251
|
+
current: effectivePermission(permissionPresets, session, pendingPermission),
|
|
1252
|
+
inPlan: preSession ? pendingPlan : store.getView().plan === true,
|
|
1253
|
+
planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
|
|
1254
|
+
})
|
|
1255
|
+
if (decision === undefined) return ''
|
|
1256
|
+
if (decision.kind === 'permission') {
|
|
1257
|
+
const next = selectPermission(permissionPresets, session, decision.preset)
|
|
1258
|
+
if (preSession) {
|
|
1259
|
+
pendingPermission = next
|
|
1260
|
+
renderCurrent()
|
|
1261
|
+
}
|
|
1262
|
+
return `permission → ${next}`
|
|
1263
|
+
}
|
|
1264
|
+
if (decision.kind === 'plan-on') {
|
|
1265
|
+
// Plan IS the most restrictive preset plus the plan prompt layer:
|
|
1266
|
+
// the cycle arrives here from that preset, so permission needs no
|
|
1267
|
+
// switch — only the plan mode itself toggles.
|
|
1268
|
+
if (preSession) {
|
|
1269
|
+
pendingPlan = true
|
|
1270
|
+
renderCurrent()
|
|
1271
|
+
return 'plan → on (applies to the first session)'
|
|
1272
|
+
}
|
|
1273
|
+
send('/plan', 'followup')
|
|
1274
|
+
return 'plan → on'
|
|
1275
|
+
}
|
|
1276
|
+
// Leaving plan lands on the station after the most restrictive
|
|
1277
|
+
// preset (workspace-write with the shipped table).
|
|
1278
|
+
if (preSession) {
|
|
1279
|
+
pendingPlan = false
|
|
1280
|
+
pendingPermission = decision.preset
|
|
1281
|
+
renderCurrent()
|
|
1282
|
+
return `plan → off · permission → ${decision.preset}`
|
|
1283
|
+
}
|
|
1284
|
+
send('/plan off', 'followup')
|
|
1285
|
+
selectPermission(permissionPresets, session, decision.preset)
|
|
1286
|
+
return `plan → off · permission → ${decision.preset}`
|
|
1287
|
+
} catch (error: unknown) {
|
|
1288
|
+
bridge.notify(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1289
|
+
return ''
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Apply one /model selection: takes effect from the next assembled step.
|
|
1295
|
+
* The optional reasoning effort must be one the row advertises (the picker
|
|
1296
|
+
* only offers those), so an unsupported value cannot reach the request
|
|
1297
|
+
* pipeline; an absent effort restores the model's own default.
|
|
1298
|
+
*/
|
|
1299
|
+
const selectModel = (row: ModelRow, effortId?: string): string => {
|
|
1300
|
+
const selection = buildModelSelection(row, effortId)
|
|
1301
|
+
if (active === undefined) {
|
|
1302
|
+
// A bare launch has no session yet: keep the pick process-wide so the
|
|
1303
|
+
// first composed session starts from it.
|
|
1304
|
+
pendingSelection = selection
|
|
1305
|
+
} else {
|
|
1306
|
+
active.selection.picked = selection
|
|
1307
|
+
}
|
|
1308
|
+
// Global default (web selectModel parity): every pick is persisted as the
|
|
1309
|
+
// deployment default through the same agentDefaultModel service the web
|
|
1310
|
+
// host writes, so the choice survives restarts and other surfaces read
|
|
1311
|
+
// it. Save failures degrade to a notice — the in-session switch already
|
|
1312
|
+
// took effect and must not roll back (the web contract).
|
|
1313
|
+
void defaultModel.saveSelection(selection).catch((error: unknown) => {
|
|
1314
|
+
bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
|
1315
|
+
})
|
|
1316
|
+
// Advisory immediate validation (web selectModel parity): run the same
|
|
1317
|
+
// local resolveCallConfig check the request pipeline would, so a stale
|
|
1318
|
+
// directory — an effort the adapter withdrew since /model loaded —
|
|
1319
|
+
// surfaces as a pick-time notice instead of failing the next assembled
|
|
1320
|
+
// step. Best-effort: an llm service without the resolver keeps the
|
|
1321
|
+
// existing request-boundary rejection. Called as a method (`this`-bound)
|
|
1322
|
+
// like resolveModelInfo in models.ts.
|
|
1323
|
+
const llm = ctx.get('llm')
|
|
1324
|
+
const resolveCallConfig = (llm as {
|
|
1325
|
+
resolveCallConfig?: (this: unknown, config: { provider: string; model: string; reasoningEffort?: string }) => Promise<unknown>
|
|
1326
|
+
} | undefined)?.resolveCallConfig
|
|
1327
|
+
if (llm !== undefined && typeof resolveCallConfig === 'function') {
|
|
1328
|
+
void Promise.resolve(resolveCallConfig.call(llm, {
|
|
1329
|
+
provider: selection.provider,
|
|
1330
|
+
model: selection.model,
|
|
1331
|
+
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
1332
|
+
})).catch((error: unknown) => {
|
|
1333
|
+
bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
return `${row.provider}/${row.model}`
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
1340
|
+
const subagentModelLabel = (): string => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride)
|
|
1341
|
+
|
|
1342
|
+
/** Apply one /subagent model pick; returns the override label. */
|
|
1343
|
+
const setSubagentModel = (row: ModelRow, effortId?: string): string => {
|
|
1344
|
+
subagentOverride = buildModelSelection(row, effortId)
|
|
1345
|
+
renderCurrent()
|
|
1346
|
+
return modelSelectionLabel(subagentOverride)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Drop the /subagent override: delegated agents follow the current model again. */
|
|
1350
|
+
const clearSubagentModel = (): void => {
|
|
1351
|
+
subagentOverride = undefined
|
|
1352
|
+
renderCurrent()
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Export the folded transcript to a markdown file (/export). The default
|
|
1357
|
+
* target sits beside the session's cwd so the file lands in the user's
|
|
1358
|
+
* workspace; an absolute or cwd-relative argument overrides it.
|
|
1359
|
+
*/
|
|
1360
|
+
const exportTranscript = async (argument: string): Promise<void> => {
|
|
1361
|
+
if (session === undefined) {
|
|
1362
|
+
bridge.notify('no session yet — submit a message to start', 'warning')
|
|
1363
|
+
return
|
|
1364
|
+
}
|
|
1365
|
+
const wanted = argument.trim()
|
|
1366
|
+
const sessionCwd = session.header.cwd ?? cwd
|
|
1367
|
+
// The default name derives from the session id, which `--session` lets the
|
|
1368
|
+
// user spell freely: reduce it to filename-safe characters first so the
|
|
1369
|
+
// default target can never escape the session cwd.
|
|
1370
|
+
const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
|
|
1371
|
+
const target = wanted === ''
|
|
1372
|
+
? join(sessionCwd, defaultName)
|
|
1373
|
+
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
1374
|
+
? wanted
|
|
1375
|
+
: join(sessionCwd, wanted)
|
|
1376
|
+
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
1377
|
+
try {
|
|
1378
|
+
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
1379
|
+
bridge.notify(`exported to ${target}`)
|
|
1380
|
+
} catch (error: unknown) {
|
|
1381
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Rename the session (/title): a user title pins the session and stops
|
|
1387
|
+
* automatic generation (the service's own contract). The appended
|
|
1388
|
+
* `session/title` event flows back through the store into the status line.
|
|
1389
|
+
*/
|
|
1390
|
+
const renameTitle = (argument: string): string => {
|
|
1391
|
+
const title = argument.trim()
|
|
1392
|
+
if (title === '') return 'usage: /title <text>'
|
|
1393
|
+
if (session === undefined) return 'no session yet — submit a message to start'
|
|
1394
|
+
const service = ctx.get('sessionTitle')
|
|
1395
|
+
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
1396
|
+
try {
|
|
1397
|
+
service.rename(session, title)
|
|
1398
|
+
return `title → ${title}`
|
|
1399
|
+
} catch (error: unknown) {
|
|
1400
|
+
return `rename failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
1405
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1406
|
+
const records = await sessionQuery.listSessions(signal)
|
|
1407
|
+
// Last-activity timestamps for sorting (codex UpdatedAt default): the
|
|
1408
|
+
// newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
|
|
1409
|
+
// the persistence `locate()` query, so paths are derived from the
|
|
1410
|
+
// backend's public config root. Backends without a JSONL config (or
|
|
1411
|
+
// vanished directories) fall back to createdAt inside the projection.
|
|
1412
|
+
const root = jsonlSessionRoot(persistence)
|
|
1413
|
+
const updated = new Map<string, number>()
|
|
1414
|
+
if (root !== undefined) {
|
|
1415
|
+
await Promise.all(records.map(async record => {
|
|
1416
|
+
try {
|
|
1417
|
+
const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
|
|
1418
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
1419
|
+
const stats = await Promise.all(
|
|
1420
|
+
entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
|
|
1421
|
+
.map(entry => stat(join(dir, entry.name))),
|
|
1422
|
+
)
|
|
1423
|
+
const newest = Math.max(...stats.map(info => info.mtimeMs))
|
|
1424
|
+
if (Number.isFinite(newest)) updated.set(record.header.id, newest)
|
|
1425
|
+
} catch {
|
|
1426
|
+
// Artifact gone or unreadable: the projection falls back to createdAt.
|
|
1427
|
+
}
|
|
1428
|
+
}))
|
|
1429
|
+
}
|
|
1430
|
+
const projected = projectSessionRows(records, options, updated)
|
|
1431
|
+
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
1432
|
+
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
1433
|
+
const page = projected.slice(0, 32)
|
|
1434
|
+
if (page.length === 0) return projected
|
|
1435
|
+
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
1436
|
+
return mergeSessionTitles(projected, observations)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Delete one session subtree (/delete, codex semantics: subagent threads go
|
|
1441
|
+
* with their root). The kernel persistence seam has NO deletion API by
|
|
1442
|
+
* design — logs accumulate "until removed externally" — so this is the
|
|
1443
|
+
* controlled external removal, in three phases with a hard boundary
|
|
1444
|
+
* between planning and touching the filesystem:
|
|
1445
|
+
*
|
|
1446
|
+
* 1. `planSessionDeletion` collects the subtree and refuses when the root
|
|
1447
|
+
* or ANY member is live (a live child would outlive its deleted
|
|
1448
|
+
* parent), ordering the plan children-first.
|
|
1449
|
+
* 2. Every plan node must derive to a guarded artifact directory
|
|
1450
|
+
* (`encodeSegment(id)` layout beneath the backend's config root).
|
|
1451
|
+
* Backends without a derivable artifact (non-JSONL) refuse the WHOLE
|
|
1452
|
+
* deletion here — no file has been touched yet, so a backend or layout
|
|
1453
|
+
* surprise can never strand a half-deleted subtree.
|
|
1454
|
+
* 3. Artifacts are removed children-first: only an I/O error mid-delete
|
|
1455
|
+
* can stop it short (reported with removed/total counts), leaving the
|
|
1456
|
+
* shallowest lineage intact.
|
|
1457
|
+
*
|
|
1458
|
+
* @param id - the root session id to delete.
|
|
1459
|
+
* @returns the outcome line for the panel/notice.
|
|
1460
|
+
*/
|
|
1461
|
+
const deleteSession = async (id: string): Promise<string> => {
|
|
1462
|
+
if (sessionQuery === undefined) return 'session query is unavailable in this profile'
|
|
1463
|
+
if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
|
|
1464
|
+
const records = await sessionQuery.listSessions()
|
|
1465
|
+
const plan = planSessionDeletion(records, id)
|
|
1466
|
+
if (!plan.ok) return plan.reason
|
|
1467
|
+
// Phase 2 completes the plan before the first rm: derive and
|
|
1468
|
+
// layout-check every node up front, so a refusal never leaves a
|
|
1469
|
+
// partially removed subtree behind.
|
|
1470
|
+
const root = jsonlSessionRoot(persistence)
|
|
1471
|
+
if (root === undefined) {
|
|
1472
|
+
return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
|
|
1473
|
+
}
|
|
1474
|
+
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
1475
|
+
const dirs = new Map<string, string>()
|
|
1476
|
+
for (const node of plan.nodes) {
|
|
1477
|
+
const record = byId.get(node.id)
|
|
1478
|
+
if (record === undefined) return `no persisted session matches "${node.id}"`
|
|
1479
|
+
const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id)
|
|
1480
|
+
if (dir === undefined) {
|
|
1481
|
+
return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`
|
|
1482
|
+
}
|
|
1483
|
+
dirs.set(node.id, dir)
|
|
1484
|
+
}
|
|
1485
|
+
let removed = 0
|
|
1486
|
+
for (const node of plan.nodes) {
|
|
1487
|
+
const dir = dirs.get(node.id)!
|
|
1488
|
+
try {
|
|
1489
|
+
// Remove every canonical generation artifact this build knows; other
|
|
1490
|
+
// sibling files are never ours to delete, and the directory itself is
|
|
1491
|
+
// only removed once empty. An unreadable directory counts as a
|
|
1492
|
+
// failure (not a silent success) so the outcome line stays honest.
|
|
1493
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
1494
|
+
for (const entry of entries) {
|
|
1495
|
+
if (entry.isFile() && isSessionArtifactName(entry.name)) {
|
|
1496
|
+
await rm(join(dir, entry.name), { force: true })
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1500
|
+
removed += 1
|
|
1501
|
+
} catch (error: unknown) {
|
|
1502
|
+
return `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
1509
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1510
|
+
const snapshot = await sessionQuery.readSession(id, signal)
|
|
1511
|
+
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const switchModeAction = async (id: string): Promise<string> => {
|
|
1515
|
+
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1516
|
+
const currentAgent = agent
|
|
1517
|
+
if (currentAgent === undefined) {
|
|
1518
|
+
const choice = pendingModeWork.then(async () => {
|
|
1519
|
+
const preset = await selectPreset(presets, undefined, id)
|
|
1520
|
+
// A resume may have won while this roster read was in flight; never
|
|
1521
|
+
// leak the old pending choice into a later /new session.
|
|
1522
|
+
if (agent === undefined) {
|
|
1523
|
+
pendingMode = preset.id
|
|
1524
|
+
renderCurrent()
|
|
1525
|
+
}
|
|
1526
|
+
return preset.id
|
|
1527
|
+
})
|
|
1528
|
+
pendingModeWork = choice.then(() => {}, () => {})
|
|
1529
|
+
return choice
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
// Serialize the recomposition with session activations: a /mode that
|
|
1534
|
+
// interleaves a switch must not rebind the shared command/skill
|
|
1535
|
+
// registries while the switch is composing the next agent.
|
|
1536
|
+
const currentActive = active
|
|
1537
|
+
const atEpoch = epoch
|
|
1538
|
+
let selected: string | undefined
|
|
1539
|
+
await compose(async () => {
|
|
1540
|
+
const preset = await selectPreset(presets, currentAgent, id)
|
|
1541
|
+
// A switch/quit landed while the recomposition ran: applying here
|
|
1542
|
+
// would write the old choice into the new session's state and rebind
|
|
1543
|
+
// the registries back to a disposed agent. The preset-selection log
|
|
1544
|
+
// entry rode the old agent's session; only the local application is
|
|
1545
|
+
// dropped.
|
|
1546
|
+
if (epoch !== atEpoch || agent !== currentAgent || active !== currentActive) {
|
|
1547
|
+
throw new Error('session changed while switching mode — nothing applied; retry in the active session')
|
|
1548
|
+
}
|
|
1549
|
+
if (active === undefined) throw new Error('active Agent has no session state')
|
|
1550
|
+
active.mode = preset.id
|
|
1551
|
+
commands.setAgent(currentAgent)
|
|
1552
|
+
skills.setAgent(currentAgent)
|
|
1553
|
+
selected = preset.id
|
|
1554
|
+
renderCurrent()
|
|
1555
|
+
})
|
|
1556
|
+
return selected!
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
1560
|
+
|
|
1561
|
+
const activate = (nextTarget: Target): Promise<void> => {
|
|
1562
|
+
if (quitting) return Promise.resolve()
|
|
1563
|
+
// Serialized with every other composition (bare-launch creation, queued
|
|
1564
|
+
// switches): at most one agent is composed at a time.
|
|
1565
|
+
return compose(async () => {
|
|
1566
|
+
const previous = active
|
|
1567
|
+
const next = await prepare(nextTarget)
|
|
1568
|
+
// Quit landed while the next session was being composed: dispose the
|
|
1569
|
+
// half-ready agent and leave the current session untouched.
|
|
1570
|
+
if (quitting) {
|
|
1571
|
+
await next.handle.dispose().catch(() => {})
|
|
1572
|
+
return
|
|
1573
|
+
}
|
|
1574
|
+
active = next
|
|
1575
|
+
agent = next.agent
|
|
1576
|
+
session = next.session
|
|
1577
|
+
store = next.store
|
|
1578
|
+
mentions = next.mentions
|
|
1579
|
+
commands.setAgent(agent)
|
|
1580
|
+
skills.setAgent(agent)
|
|
1581
|
+
try {
|
|
1582
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1583
|
+
renderCurrent()
|
|
1584
|
+
// Only a successful handoff may clear the transient per-session
|
|
1585
|
+
// surfaces: a rolled-back switch keeps the previous session's
|
|
1586
|
+
// subagent feed plus the user's pre-session /mode and permission
|
|
1587
|
+
// picks (the bare-launch promise: explicit choices survive until
|
|
1588
|
+
// composition takes them).
|
|
1589
|
+
subagents.reset()
|
|
1590
|
+
pendingMode = undefined
|
|
1591
|
+
pendingPermission = undefined
|
|
1592
|
+
pendingPlan = false
|
|
1593
|
+
} catch (error: unknown) {
|
|
1594
|
+
active = previous
|
|
1595
|
+
agent = previous?.agent
|
|
1596
|
+
session = previous?.session
|
|
1597
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
1598
|
+
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1599
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
1600
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
1601
|
+
await next.handle.dispose()
|
|
1602
|
+
if (!quitting) renderCurrent()
|
|
1603
|
+
throw error
|
|
1604
|
+
}
|
|
1605
|
+
// From here the new session is live: in-flight prepares/commands for
|
|
1606
|
+
// the previous agent are stale and must be aborted and ignored.
|
|
1607
|
+
abortPendingControllers()
|
|
1608
|
+
epoch += 1
|
|
1609
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
1610
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
1611
|
+
if (previous === undefined) {
|
|
1612
|
+
// The key-change remount above swaps the App in this same synchronous
|
|
1613
|
+
// continuation; the new App registers its bridge.notify in a passive
|
|
1614
|
+
// effect AFTER it, so an immediate notice reaches the UNMOUNTED
|
|
1615
|
+
// instance and React drops it silently. Defer past the commit.
|
|
1616
|
+
setTimeout(() => {
|
|
1617
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
1618
|
+
}, 0)
|
|
1619
|
+
return
|
|
1620
|
+
}
|
|
1621
|
+
let cleanupWarning: string | undefined
|
|
1622
|
+
try {
|
|
1623
|
+
await sessions.flush(previous.session)
|
|
1624
|
+
} catch (error: unknown) {
|
|
1625
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
await previous.handle.dispose()
|
|
1629
|
+
} catch (error: unknown) {
|
|
1630
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1631
|
+
}
|
|
1632
|
+
bridge.notify(cleanupWarning === undefined
|
|
1633
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
1634
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
1635
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
1640
|
+
async request => { if (!quitting) await activate(request.target) },
|
|
1641
|
+
error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
const requestSwitch = (request: PendingSwitch): void => {
|
|
1645
|
+
if (session === undefined) {
|
|
1646
|
+
// No session yet (a bare launch using /resume before any input): activate
|
|
1647
|
+
// the target directly — there is no running turn to wait on and nothing
|
|
1648
|
+
// to flush.
|
|
1649
|
+
void activate(request.target).catch((error: unknown) => {
|
|
1650
|
+
bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1651
|
+
})
|
|
1652
|
+
return
|
|
1653
|
+
}
|
|
1654
|
+
if (request.target.sessionId === session.id) {
|
|
1655
|
+
bridge.notify('that session is already active', 'warning')
|
|
1656
|
+
return
|
|
1657
|
+
}
|
|
1658
|
+
const outcome = switchQueue.request(agent!, request)
|
|
1659
|
+
if (outcome === 'queued') {
|
|
1660
|
+
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const resolveResumeId = async (wanted: string): Promise<string> => {
|
|
1665
|
+
if (wanted === '') throw new Error('usage: /resume <id|prefix>')
|
|
1666
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1667
|
+
const records = await sessionQuery.listSessions()
|
|
1668
|
+
const exact = records.filter(record => record.header.id === wanted)
|
|
1669
|
+
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
1670
|
+
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
1671
|
+
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
1672
|
+
const matched = matches[0]!
|
|
1673
|
+
// Same lineage gate as the CLI --resume path and the picker.
|
|
1674
|
+
if (isSubagentSession(matched.header)) {
|
|
1675
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
1676
|
+
}
|
|
1677
|
+
if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
|
|
1678
|
+
throw new Error('that session is already live in another owner')
|
|
1679
|
+
}
|
|
1680
|
+
return matched.header.id
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const requestResume = (wanted: string): void => {
|
|
1684
|
+
void resolveResumeId(wanted).then(id => {
|
|
1685
|
+
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
1686
|
+
}, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const createSession = (mode?: string): void => {
|
|
1690
|
+
// /new before any input is the first-session creation itself, not a switch.
|
|
1691
|
+
if (session === undefined) {
|
|
1692
|
+
ensureSession(mode)
|
|
1693
|
+
return
|
|
1694
|
+
}
|
|
1695
|
+
const nextCwd = session.header.cwd ?? cwd
|
|
1696
|
+
const id = `session-${randomUUID()}`
|
|
1697
|
+
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const reviewChanges = (argument: string): void => {
|
|
1701
|
+
// Works from a bare launch too: with no session yet the read-only
|
|
1702
|
+
// choice goes to pendingPermission (materialized when the first
|
|
1703
|
+
// session composes) and the review prompt queues behind that
|
|
1704
|
+
// creation exactly like a typed first submission. The identity guard
|
|
1705
|
+
// below still aborts a load that outlives a mid-flight switch —
|
|
1706
|
+
// including one landing on an undefined agent.
|
|
1707
|
+
const currentAgent = agent
|
|
1708
|
+
// The diff loads from the CALLING session's cwd; capture that
|
|
1709
|
+
// workspace and this turn's identity so a switch mid-load can neither
|
|
1710
|
+
// flip the new session read-only nor send the old workspace's review
|
|
1711
|
+
// into it. The controller rides pendingControllers, so a switch/quit
|
|
1712
|
+
// kills the git subprocess itself instead of only ignoring its result.
|
|
1713
|
+
const atEpoch = epoch
|
|
1714
|
+
const reviewCwd = session?.header.cwd ?? cwd
|
|
1715
|
+
const controller = new AbortController()
|
|
1716
|
+
pendingControllers.add(controller)
|
|
1717
|
+
const finish = (): void => {
|
|
1718
|
+
pendingControllers.delete(controller)
|
|
1719
|
+
}
|
|
1720
|
+
void loadGitDiff(reviewCwd, argument, controller.signal).then(({ title, files }) => {
|
|
1721
|
+
finish()
|
|
1722
|
+
if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent) return
|
|
1723
|
+
try {
|
|
1724
|
+
setPermissionAction('read-only')
|
|
1725
|
+
} catch (error: unknown) {
|
|
1726
|
+
bridge.notify(`review unavailable: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1727
|
+
return
|
|
1728
|
+
}
|
|
1729
|
+
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title), 'followup')
|
|
1730
|
+
bridge.notify('review started under read-only permissions')
|
|
1731
|
+
}, (error: unknown) => {
|
|
1732
|
+
finish()
|
|
1733
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1734
|
+
bridge.notify(`review failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1735
|
+
})
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const forkSession = (argument: string): void => {
|
|
1739
|
+
if (session === undefined || active === undefined) {
|
|
1740
|
+
bridge.notify('no session yet - submit a message to start', 'warning')
|
|
1741
|
+
return
|
|
1742
|
+
}
|
|
1743
|
+
try {
|
|
1744
|
+
const text = argument.trim()
|
|
1745
|
+
const atSeq = text === '' ? undefined : Number(text)
|
|
1746
|
+
if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
|
|
1747
|
+
throw new Error('usage: /fork [event-seq]')
|
|
1748
|
+
}
|
|
1749
|
+
const seed = selectForkSeed(session.snapshotEvents(), atSeq)
|
|
1750
|
+
const id = `session-${randomUUID()}`
|
|
1751
|
+
requestSwitch({
|
|
1752
|
+
target: {
|
|
1753
|
+
sessionId: id,
|
|
1754
|
+
resume: false,
|
|
1755
|
+
mode: active.mode,
|
|
1756
|
+
cwd: session.header.cwd ?? cwd,
|
|
1757
|
+
seed: seed.events,
|
|
1758
|
+
parentSession: session.id,
|
|
1759
|
+
seedLength: seed.events.length,
|
|
1760
|
+
},
|
|
1761
|
+
label: id.slice(-12),
|
|
1762
|
+
})
|
|
1763
|
+
} catch (error: unknown) {
|
|
1764
|
+
bridge.notify(`fork failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const switchSession = (row: SessionRow): void => {
|
|
1769
|
+
if (!row.resumable) {
|
|
1770
|
+
bridge.notify('subagent conversations are read-only', 'warning')
|
|
1771
|
+
return
|
|
1772
|
+
}
|
|
1773
|
+
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const cancelSessionSwitch = (): boolean => {
|
|
1777
|
+
return switchQueue.cancel()
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
const appElement = (): ReturnType<typeof createElement> => {
|
|
1781
|
+
// A bare launch mounts with pending/default model, mode, and permission
|
|
1782
|
+
// facts until the first input composes the real session. These choices stay
|
|
1783
|
+
// process-local and create no durable state before that composition.
|
|
1784
|
+
const sessionCwd = session?.header.cwd ?? cwd
|
|
1785
|
+
const currentView = store.getView()
|
|
1786
|
+
const defaults = currentDefaults()
|
|
1787
|
+
const model = currentView.model !== ''
|
|
1788
|
+
? currentView.model
|
|
1789
|
+
: pendingSelection !== undefined
|
|
1790
|
+
? `${pendingSelection.provider}/${pendingSelection.model}`
|
|
1791
|
+
: `${defaults.provider}/${defaults.model}`
|
|
1792
|
+
const effort = resolveEffectiveSelection(
|
|
1793
|
+
active?.selection.picked ?? pendingSelection,
|
|
1794
|
+
session?.requestHeader()?.config,
|
|
1795
|
+
defaults,
|
|
1796
|
+
).reasoningEffort
|
|
1797
|
+
const permission = permissionPresets === undefined
|
|
1798
|
+
? currentView.permission
|
|
1799
|
+
: effectivePermission(permissionPresets, session, pendingPermission)
|
|
1800
|
+
return createElement(App, {
|
|
1801
|
+
key: session?.id ?? 'pending',
|
|
1802
|
+
sessionKey: session?.id ?? '',
|
|
1803
|
+
store,
|
|
1804
|
+
approval,
|
|
1805
|
+
questions,
|
|
1806
|
+
subagents,
|
|
1807
|
+
commands,
|
|
1808
|
+
skills,
|
|
1809
|
+
model,
|
|
1810
|
+
effort,
|
|
1811
|
+
cwd: basename(sessionCwd),
|
|
1812
|
+
workspaceRoot: sessionCwd,
|
|
1813
|
+
branch: gitBranch(sessionCwd),
|
|
1814
|
+
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
1815
|
+
resumed: active?.resumed ?? false,
|
|
1816
|
+
mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
|
|
1817
|
+
permission,
|
|
1818
|
+
/** Pre-session plan choice for the status badge until a session composes. */
|
|
1819
|
+
pendingPlan: session === undefined && pendingPlan,
|
|
1820
|
+
dispatch,
|
|
1821
|
+
steer,
|
|
1822
|
+
interrupt,
|
|
1823
|
+
quit,
|
|
1824
|
+
loadModels: () => loadModelDirectory(ctx),
|
|
1825
|
+
loadModelProviders: () => loadProviderSettings(ctx),
|
|
1826
|
+
subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
|
|
1827
|
+
saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
|
|
1828
|
+
saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
|
|
1829
|
+
discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
|
|
1830
|
+
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
1831
|
+
removeModelProvider: target => removeProviderSettings(ctx, target),
|
|
1832
|
+
loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
|
|
1833
|
+
subscribeProviderAuthorizations: listener => subscribeProviderAuthorizations(ctx, listener),
|
|
1834
|
+
beginProviderAuthorization: (row, method, interaction, signal) => (
|
|
1835
|
+
beginProviderAuthorization(ctx, row, method, interaction, signal)
|
|
1836
|
+
),
|
|
1837
|
+
cancelProviderAuthorization: row => cancelProviderAuthorization(ctx, row.key),
|
|
1838
|
+
logoutProviderAuthorization: row => logoutProviderAuthorization(ctx, row),
|
|
1839
|
+
openAuthorizationUrl,
|
|
1840
|
+
copyTextValue: copyText,
|
|
1841
|
+
loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
|
|
1842
|
+
inspectImages: paths => inspectImagePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
|
|
1843
|
+
prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
|
|
1844
|
+
inspectFiles: paths => inspectFilePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
|
|
1845
|
+
prepareFiles: (paths, signal) => saveFilePaths(paths, ctx.get('attachments'), signal),
|
|
1846
|
+
cycleMode,
|
|
1847
|
+
setPermission: setPermissionAction,
|
|
1848
|
+
selectModel,
|
|
1849
|
+
subagentModel: subagentModelLabel(),
|
|
1850
|
+
setSubagentModel,
|
|
1851
|
+
clearSubagentModel,
|
|
1852
|
+
deleteSession,
|
|
1853
|
+
exportTranscript,
|
|
1854
|
+
renameTitle,
|
|
1855
|
+
copyLastResponse,
|
|
1856
|
+
loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
|
|
1857
|
+
reviewChanges,
|
|
1858
|
+
loadPresets: () => presets.list(),
|
|
1859
|
+
switchMode: switchModeAction,
|
|
1860
|
+
loadPermissions: () => permissionPresets === undefined
|
|
1861
|
+
? Promise.reject(new Error('permission presets are not mounted in this composition'))
|
|
1862
|
+
: Promise.resolve(listPermissionRows(permissionPresets)),
|
|
1863
|
+
createSession,
|
|
1864
|
+
forkSession,
|
|
1865
|
+
loadSessions,
|
|
1866
|
+
loadSessionTranscript,
|
|
1867
|
+
loadSubagents: () => {
|
|
1868
|
+
const current = session
|
|
1869
|
+
if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
|
|
1870
|
+
return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
|
|
1871
|
+
.then(rows => rows.filter(row => row.parent === current.id && row.subagent))
|
|
1872
|
+
},
|
|
1873
|
+
switchSession,
|
|
1874
|
+
cancelSessionSwitch,
|
|
1875
|
+
loadPlugins: () => listPluginRows(ctx),
|
|
1876
|
+
// The launcher owns every update decision; the TUI only drives its
|
|
1877
|
+
// read-only probe and streamed apply as child processes.
|
|
1878
|
+
probeUpdate: () => probeLauncherUpdate(),
|
|
1879
|
+
applyUpdate: onLine => applyLauncherUpdate(onLine),
|
|
1880
|
+
loadJobs: () => listJobs(ctx, active?.agent),
|
|
1881
|
+
statusline: statuslineItems,
|
|
1882
|
+
saveStatusline,
|
|
1883
|
+
applyEditorKeys,
|
|
1884
|
+
saveTheme,
|
|
1885
|
+
animations: animationsEnabled,
|
|
1886
|
+
saveAnimations,
|
|
1887
|
+
history: inputHistory,
|
|
1888
|
+
recordHistory,
|
|
1889
|
+
cancelQueued,
|
|
1890
|
+
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
1891
|
+
})
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
const renderCurrent = (): void => {
|
|
1895
|
+
mountRef.current?.rerender(appElement())
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
mountRef.current = io.mount(appElement())
|
|
1899
|
+
|
|
1900
|
+
// Startup prompt/images use the same durable delivery path as composer
|
|
1901
|
+
// submissions. Image bytes are committed before the user/message event, and
|
|
1902
|
+
// input typed during that preparation queues behind the initial request so
|
|
1903
|
+
// the agent always receives the startup prompt first.
|
|
1904
|
+
if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
|
|
1905
|
+
if ((startup.images?.length ?? 0) > 0) {
|
|
1906
|
+
bridge.notify(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
|
|
1907
|
+
}
|
|
1908
|
+
void inputGate.run(async deliver => {
|
|
1909
|
+
const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'))
|
|
1910
|
+
if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
|
|
1911
|
+
deliver({ text: startup.prompt ?? '', mode: 'followup', images })
|
|
1912
|
+
}).catch((error: unknown) => {
|
|
1913
|
+
bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1914
|
+
})
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async function copyLastResponse(): Promise<string> {
|
|
1918
|
+
const text = latestAssistantText(store.getView())
|
|
1919
|
+
if (text === undefined) return 'nothing to copy yet'
|
|
1920
|
+
await copyText(text)
|
|
1921
|
+
return 'copied latest response'
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// A corrupt statusline config must not vanish silently: surface it once
|
|
1925
|
+
// the notice channel is live, after the first frame settles.
|
|
1926
|
+
if (statuslineWarning !== undefined) {
|
|
1927
|
+
setTimeout(() => {
|
|
1928
|
+
bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
|
|
1929
|
+
}, 50)
|
|
1930
|
+
}
|
|
1931
|
+
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
1932
|
+
if (themeWarning !== undefined) {
|
|
1933
|
+
setTimeout(() => {
|
|
1934
|
+
bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
|
|
1935
|
+
}, 50)
|
|
1936
|
+
}
|
|
1937
|
+
// And for a corrupt animations file (on-by-default fallback stays live).
|
|
1938
|
+
if (animationsWarning !== undefined) {
|
|
1939
|
+
setTimeout(() => {
|
|
1940
|
+
bridge.notify('animations config unreadable, animations stay on: ' + animationsWarning, 'warning')
|
|
1941
|
+
}, 50)
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// One-shot VS Code Ctrl+R hint: resolveEditorKeysStartupHint checks the
|
|
1945
|
+
// marker file and the live keybindings config; surfacing waits for the
|
|
1946
|
+
// notice channel like the other startup warnings. A failed probe stays
|
|
1947
|
+
// silent — the hint is cosmetic and /vscode-keys remains discoverable.
|
|
1948
|
+
void resolveEditorKeysStartupHint(editorKeysEnv).then(hint => {
|
|
1949
|
+
if (hint === undefined) return
|
|
1950
|
+
setTimeout(() => {
|
|
1951
|
+
bridge.notify(hint)
|
|
1952
|
+
}, 50)
|
|
1953
|
+
}, () => {})
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Mount the interactive terminal driver.
|
|
1958
|
+
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
1959
|
+
* @param config - validated startup config resolved from the tuiStartup provider.
|
|
1960
|
+
*/
|
|
1961
|
+
export function apply(ctx: Context, config: Config): void {
|
|
1962
|
+
// The CLI validated --theme at parse time; the loose config schema falls
|
|
1963
|
+
// back to dark for anything unexpected.
|
|
1964
|
+
const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme)
|
|
1965
|
+
const input = {
|
|
1966
|
+
...(theme === undefined ? {} : { theme }),
|
|
1967
|
+
...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
|
|
1968
|
+
...(config.startup.images === undefined ? {} : { images: config.startup.images }),
|
|
1969
|
+
}
|
|
1970
|
+
const startup: TuiStartup =
|
|
1971
|
+
config.startup.kind === 'resume' && config.startup.sessionId !== undefined
|
|
1972
|
+
? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
|
|
1973
|
+
: config.startup.kind === 'latest'
|
|
1974
|
+
? { kind: 'latest', ...input }
|
|
1975
|
+
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
1976
|
+
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
1977
|
+
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
1978
|
+
// Read through the global service store, not the property proxy: appExit is
|
|
1979
|
+
// an optional host value, never an injected dependency.
|
|
1980
|
+
const exit = ctx.get('appExit')
|
|
1981
|
+
if (exit === undefined) {
|
|
1982
|
+
throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
|
|
1983
|
+
}
|
|
1984
|
+
const io: TuiIo = { mount: internals.mount, exit }
|
|
1985
|
+
void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
|
|
1986
|
+
}
|