Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
2024 lines (1950 loc) · 99.5 KB
/
Copy pathindex.js
File metadata and controls
2024 lines (1950 loc) · 99.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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
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
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
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
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
/**
* dsh-gitbash-shell — host half (route B: the own bash tool + preset variants).
*
* Installed as a profile bundle, this plugin gives dsh a Git Bash command
* channel WITHOUT touching the host's official PowerShell executor (route B,
* v0.39.0 — the executor instance lives in ./shell.js and is constructed in a
* private scope for the plugin's own bash tool). On startup it also registers
* four Git Bash agent preset variants, mirroring how dsh-ptc-cordis-preset
* ships its 'ptc-cordis' preset:
*
* standard-gitbash — 标准模式 · Git Bash (tool-bash on, tool-pwsh off)
* minimal-gitbash — 极简模式 · Git Bash (persistent Git Bash terminal)
* code-gitbash — PTC 模式 · Git Bash (Code Mode presentation)
* cordis-gitbash — 创造模式 · Git Bash (self-modifying toolset; skills
* copied from the INSTALLED shipped 'cordis' preset so
* the guidance tracks the deployment)
*
* Presets are registered declaratively through agentPresets.register(); the
* composition data lives in src/compositions.js. Earlier releases materialized
* preset directories with a .plugin-managed.json marker. That path is gone, but
* cleanupLegacyTrees() still removes an UNMODIFIED tree left behind by an old
* version — a user-modified or foreign tree is never touched.
*/
import { createHash } from 'node:crypto'
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
import { loadSchemastery } from './schemastery.js'
import { applyToolRouting, mountInlineTools } from './inline-tools.js'
import { adoptTerminalBundlePatch, detectTerminalBundle, terminalBundleReport, terminalDialect } from './terminal-bundle.js'
import { resolveHostModule } from './bash-tool.js'
import { mountFastctx } from './fastctx-bridge.js'
import {
ERROR_CONTENT_TOOLS,
GIT_MOUNTS,
PATH_DIALECT_DESCRIPTION,
PATH_DIALECT_KEY,
PATH_DIALECT_VALUE,
POSIX_DIRECTIVE_TEXT,
POSIX_DIRECTIVE_TEXT_STRICT,
buildTranslateEnv,
driveToMsys,
msysEcho,
pathEcho,
programPrelude,
rewriteCodePaths,
rewriteErrorContent,
rewriteErrorMessage,
rewriteFailureMessage,
rewriteResultPaths,
runCodeHintFor,
scanCodeLiterals,
translateDispatch,
translateGlobArguments,
translateMsysPath,
translatePathArguments,
windowsToMsys,
} from './path-dialect.js'
export * from './path-dialect.js'
/**
* The schemastery module, resolved LAZILY: `@deepseek-ai/schemastery` is a
* PEER — plain Node cannot resolve it from this package, only the host's own
* resolution (the profile shared fallback) supplies it. A static peer import
* failure makes the Loader skip the whole plugin row as non-fatal
* (`vendor/loader/src/config/entry.ts` `_init()`: logger.error + return, no
* fiber), so the import stays dynamic.
*
* WHICH copy is resolved is not left to our own resolution order: the resolver
* picks a copy that can produce the Volatile refs the host executor reads
* (src/schemastery.js). Both call sites share one cached answer, so the row
* Config and the executor Config cannot disagree.
*/
let Schema = null
try {
Schema = (await loadSchemastery()).z ?? null
} catch (error) {
Schema = null
console.warn(
'[gitbash-shell] @deepseek-ai/schemastery is not resolvable here; the row Config surface is absent'
+ ' (the preset registration and the client half do not depend on it): '
+ (error && error.message || String(error)),
)
}
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-gitbash-shell'
/** The preset roster is a hard dependency: without it there is nothing to do. */
export const inject = ['agentPresets']
const TAG = '[gitbash-shell]'
const MANAGED_BY = 'dsh-gitbash-shell'
const MARKER_FILE = '.plugin-managed.json'
/** Default preset ids this plugin materializes, in roster order (configurable). */
export const PRESET_IDS = ['standard-gitbash', 'minimal-gitbash', 'code-gitbash', 'cordis-gitbash']
/**
* Variant preset id -> the OFFICIAL successor id, for the deployment-default
* migration (v0.35.0). Every target is verified against the LIVE roster
* before any write — the official ids renamed once already (code→ptc), so a
* missing successor stands the migration down instead of writing an unknown
* id. `code-gitbash` deliberately maps to `ptc`: that variant has described
* the official PTC mode since dsh 0.1.2 renamed it.
*/
export const VARIANT_DEFAULT_SUCCESSORS = Object.freeze({
'standard-gitbash': 'standard',
'minimal-gitbash': 'minimal',
'code-gitbash': 'ptc',
'cordis-gitbash': 'cordis',
})
/**
* The variant dsh-ptc-cordis-preset already covers while cooperating: its
* `PTC 创造模式` is materialized against Git Bash too, so our own
* `创造模式 · Git Bash` describes the same mode (their issue #7).
*/
export const PEER_COVERED_PRESET_ID = 'cordis-gitbash'
/**
* Service dsh-ptc-cordis-preset publishes:
* `{ id, gitBashActive, pythonRuntime, pythonBackend? }`. `pythonRuntime` is
* the user's INTENT for the experimental CPython run_code backend (the field
* the settings card writes); `pythonBackend` ('python' | 'node', v0.15.1+,
* additive) is the backend that is ACTUALLY active after the peer's own
* preflight — package resolvable, interpreter qualified, POSIX host.
*/
export const PEER_CAPABILITY = 'ptcCordisPreset'
/**
* The peer's CPython intent field — on its capability AND on its row Config
* (the field both settings cards read and write).
*/
export const PEER_PYTHON_FIELD = 'pythonRuntime'
/** The peer's EFFECTIVE backend field on the capability ('python' | 'node'). */
export const PEER_PYTHON_BACKEND_FIELD = 'pythonBackend'
/**
* Read one boolean fact off the peer's capability object. A getter is honoured
* so a live peer can report its current switch rather than a boot-time copy;
* anything absent, of the wrong type, or throwing reads as false — the
* conservative direction is always "the official Node backend is in use".
* @param {object|undefined} capability - the peer's published capability.
* @param {string} field - the field name to read.
* @returns {boolean} the fact, false when it cannot be established.
*/
export function peerFact(capability, field) {
if (capability === null || capability === undefined) return false
try {
const raw = capability[field]
const value = typeof raw === 'function' ? raw.call(capability) : raw
return value === true
} catch {
return false
}
}
/**
* The peer's EFFECTIVE run_code backend, as the closed two-value vocabulary it
* publishes. `undefined` means "the peer does not report one" — an older peer,
* a getter that threw, or a value outside the vocabulary — and every caller
* must then fall back to the INTENT, which is the pre-v0.15.1 behavior.
* @param {object|undefined} capability - the peer's published capability.
* @returns {'python'|'node'|undefined} the reported backend.
*/
export function peerBackend(capability) {
if (capability === null || capability === undefined) return undefined
try {
const raw = capability[PEER_PYTHON_BACKEND_FIELD]
const value = typeof raw === 'function' ? raw.call(capability) : raw
return value === 'python' || value === 'node' ? value : undefined
} catch {
return undefined
}
}
/**
* Is the experimental CPython backend ACTUALLY the one run_code will use? The
* composition's workflow mutex keys on this, never on the bare intent: when the
* peer's preflight fails (package removed, interpreter too old, win32) the
* composition still runs Node, so forcing the workflow rows off would cost the
* user a capability for nothing.
*
* An older peer without the effective field reports no backend, and the intent
* stands in for it — exactly the pre-v0.15.1 behavior, so the linkage never
* changes shape because a field is missing.
* @param {object|undefined} capability - the peer's published capability.
* @returns {boolean} true only while the CPython backend is the active one.
*/
export function pythonBackendActive(capability) {
const intent = peerFact(capability, PEER_PYTHON_FIELD)
const reported = peerBackend(capability)
if (reported === undefined) return intent
return intent && reported === 'python'
}
/**
* Is this agent a DELEGATED one — a subagent, a team member, or a nested child
* of either? The canonical in-tree signal is the session header pair dsh's own
* workspace-changes plugin uses
* (`packages/deliverables/workspace-changes/src/index.ts:59-60`), stamped when
* the subagent driver creates a child
* (`packages/subagent/subagent/src/child-agent.ts:139-155`):
* `origin === 'subagent'` or a non-zero `delegationDepth`.
*
* Unknown shapes read as NOT delegated: applying the dialect is the helpful
* direction, and a diagnostic assembly without an agent must keep it.
* @param {object|undefined} agent - the assembling/executing agent, when known.
* @returns {boolean} true only when the agent is provably delegated.
*/
export function isDelegatedAgent(agent) {
try {
const header = agent && agent.session ? agent.session.header : undefined
if (header === null || typeof header !== 'object') return false
if (header.origin === 'subagent') return true
return typeof header.delegationDepth === 'number' && header.delegationDepth > 0
} catch {
return false
}
}
/**
* Does the Git Bash dialect apply to THIS agent (v0.27.0)? The row switch
* `subagentDialect` (default ON) decides whether delegated agents — subagents,
* team members, nested children — participate in the dialect at all; the main
* agent always does. A closed function so every consumer (prompt assembly,
* dispatch translation, result echo, failure text, the shell-env fact) answers
* the same way.
*
* Note the honest bound: dsh has exactly ONE shell provider per process, so
* the executor binary stays Git Bash for every agent; this gate governs the
* dialect/translation layers that this plugin owns.
* @param {object} dialect - the live dialect switches.
* @param {object|undefined} agent - the agent that owns this assembly/execution.
* @returns {boolean} true when the dialect applies to this agent.
*/
export function dialectApplies(dialect, agent) {
if (!dialect || dialect.subagentDialect !== false) return true
return !isDelegatedAgent(agent)
}
/**
* The roster this plugin serves: the configured list, minus the variant a peer
* already covers. Two independent facts must BOTH hold before anything is
* dropped — the user's `suppressPeerCordis` switch (row Config, default OFF)
* and the peer actually reporting a Git Bash-materialized preset. A peer that
* is absent, inactive, or mounted later than this row therefore never costs
* the user a variant, and the default keeps the historical four-variant roster
* byte for byte.
* @param {string[]} configured - the row's `presets` list (empty/absent = default).
* @param {object} [options] - live decision inputs.
* @param {boolean} [options.suppress] - the live `suppressPeerCordis` switch.
* @param {boolean} [options.peerGitBash] - the peer capability's `gitBashActive`.
* @returns {string[]} the ids to register (or materialize), in configured order.
*/
export function effectivePresetIds(configured, { suppress, peerGitBash } = {}) {
const list = Array.isArray(configured) && configured.length > 0 ? [...configured] : [...PRESET_IDS]
if (suppress !== true || peerGitBash !== true) return list
return list.filter((id) => id !== PEER_COVERED_PRESET_ID)
}
/**
* The ONE bash resolver (v0.28.0, issue #11): the executor (src/shell.js) and
* this translation layer read the same memoized result, so "dialect translated
* to Q:/Git but the executor still spawns C:/Program Files/Git" cannot happen.
*/
import {
DEFAULT_GIT_BASH, GIT_BASH_DOWNLOAD_URL, bashResolutionReport,
effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached,
} from './bash-path.js'
import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
import {
adoptOpsBashRemoval,
opsAdoptReport,
opsRestoreReport,
restoreOpsBash,
} from './ops-adopt.js'
import {
cancelToolJob, clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
probeToolchain, readJsonBody, startToolJob,
} from './tool-runner.js'
/** The shipped preset whose skills/ dir seeds cordis-gitbash. */
// ── tree hashing ────────────────────────────────────────────────────────────
/** Every file under `root`, as sorted relative POSIX-style paths. */
function walkFiles(root, rel = '') {
const out = []
let entries
try {
entries = readdirSync(join(root, rel), { withFileTypes: true })
} catch {
return out
}
for (const e of entries) {
const r = rel ? `${rel}/${e.name}` : e.name
if (e.isDirectory()) out.push(...walkFiles(root, r))
else if (e.isFile()) out.push(r)
}
return out.sort()
}
/** Map of relative path → sha256 for every file under `root`. */
function hashTree(root) {
const files = {}
for (const rel of walkFiles(root)) {
if (rel === MARKER_FILE) continue
files[rel] = createHash('sha256').update(readFileSync(join(root, rel))).digest('hex')
}
return files
}
/** The parsed marker, or null when the tree is not ours. */
function readMarker(target) {
try {
const m = JSON.parse(readFileSync(join(target, MARKER_FILE), 'utf8'))
if (m && m.managedBy === MANAGED_BY && m.files && typeof m.files === 'object') return m
} catch {
/* absent or unreadable → not ours */
}
return null
}
/** Classify a preset directory: absent | foreign | user-modified | unmodified. */
function classify(target) {
if (!existsSync(target)) return 'absent'
const marker = readMarker(target)
if (!marker) return 'foreign'
const current = hashTree(target)
const recorded = marker.files
const keys = Object.keys(recorded)
if (keys.length !== Object.keys(current).length) return 'user-modified'
for (const k of keys) if (current[k] !== recorded[k]) return 'user-modified'
return 'unmodified'
}
// ── inspect-registry compatibility shim ─────────────────────────────────────
//
// The host-plane runner's inspect registry is a process-global singleton and
// its `register` THROWS on a duplicate provider id. `tool-cordis` registers
// the same provider ids from every preset that mounts it, so a process
// hosting both the built-in Creation mode and any cordis-derived preset
// (cordis-gitbash here, ptc-cordis from dsh-ptc-cordis-preset) fails the
// SECOND mount. Two registrants of the SAME package produce identical
// manifests, so replacing the stored entry instead of throwing is a no-op for
// consumers, and the identity-guarded disposer keeps teardown consistent.
// Installed once at boot by this always-mounted host row, so every later
// cordis toolset mount coexists.
const SHIM_FLAG = '__gitbashShellRegisterShim'
/** Wrap one inspect registry's `register` to tolerate duplicate registrations. */
export function installRegisterShim(reg) {
const restore = () => {}
if (!reg || typeof reg.register !== 'function' || !(reg.providers instanceof Map)) {
return { installed: false, restore }
}
if (reg.register[SHIM_FLAG] === true) return { installed: false, restore }
const original = reg.register
try {
const wrapped = function register(registration) {
try {
return original.call(this, registration)
} catch (error) {
const message = error && error.message ? error.message : String(error)
if (!message.includes('is already registered')) throw error
const manifest = registration && registration.manifest
if (!manifest || typeof manifest.id !== 'string') throw error
const stored = { ...registration, manifest }
this.providers.set(manifest.id, stored)
const self = this
return () => {
if (self.providers.get(manifest.id) === stored) self.providers.delete(manifest.id)
}
}
}
try { Object.defineProperty(wrapped, SHIM_FLAG, { value: true }) } catch { /* cosmetic */ }
reg.register = wrapped
return {
installed: true,
restore: () => {
try { if (reg.register === wrapped) reg.register = original } catch { /* never block */ }
},
}
} catch {
return { installed: false, restore }
}
}
// ── unified path dialect: settings namespace + gate (v0.9.0) ───────────────
//
// The dialect (directive + translation wrapper) is OFF by default and gated
// by the posixPaths boolean on this plugin's row Config (volatile, read live
// per dispatch), flipped from the plugin's settings card (src/client.js). The
// directive's text closure returns '' while off — empty context contributions
// are dropped at assembly, so a disabled dialect adds zero prompt noise; the
// wrapper reads the same value per dispatch and passes calls through.
/**
* Row Config = this plugin's live settings surface (values persist under the
* row id 'gitbash-shell'). Every editable field is volatile, so a change lands
* on the next read without a remount. Absent (undefined) when schemastery is
* unresolvable: cordis then passes the row config through unvalidated instead
* of the whole row disappearing.
*/
export const Config = Schema === null ? undefined : Schema.object({
// Row-level knob predating the Config surface (invariant 6): which preset
// variants to serve. Ordinary config — an edit remounts the plugin, which
// is the right weight for a list that changes the roster.
presets: Schema.array(Schema.string()).default(PRESET_IDS),
posixPaths: Schema.boolean().default(true).volatile(),
virtualMounts: Schema.boolean().default(true).volatile(),
globSplit: Schema.boolean().default(true).volatile(),
errorDialect: Schema.boolean().default(true).volatile(),
codePaths: Schema.boolean().default(true).volatile(),
/**
* Whether DELEGATED agents (subagents, team members, nested children) also
* use the Git Bash dialect (v0.27.0). Default ON — the historical behavior;
* OFF keeps the dialect to the main agent, so a delegated request carries the
* official shell semantics (no prompt rewrite, no path-argument translation,
* no echo rewrite, no DSH_PATH_DIALECT fact). The executor itself is one
* per-process provider, so Git Bash stays the binary either way.
*/
subagentDialect: Schema.boolean().default(true).volatile(),
gitAutocrlf: Schema.boolean().default(true).volatile(),
bashPath: Schema.string().default('').volatile(),
/**
* Bash init script (issue #37, v0.45.0): path of a file every `bash -c`
* must source before running the command. Implemented as `BASH_ENV`, the
* shell's OWN hook for non-interactive shells — `bash -c` sources it once,
* with none of `-l`'s side effects (no `cd $HOME`, no PATH reordering, no
* profile output in stdout). The reported case is fnm/nvm-style node
* managers: `eval "$(fnm env)"` in that file makes node/npm visible to every
* tool call. Empty (default) = BASH_ENV is never set, behavior unchanged.
* `~` expands to the home directory; the path must exist (a missing file is
* silently ignored by bash, so a typo looks like "not set").
*/
initScript: Schema.string().default('').volatile(),
adoptSidebar: Schema.boolean().default(true).volatile(),
/**
* Whether the OFFICIAL sidebar terminal ("new terminal") is switched to the
* resolved Git Bash (v0.29.0, task-25). Default ON — the user asked for
* exactly this ("自动检测 … 帮忙补上一个 gitbash 可以用"). On Windows without
* this, dsh resolves the terminal shell from the environment, which on a
* machine whose PATH `bash` is the WSL launcher means the new terminal starts
* Ubuntu, not Git Bash. Only ever writes when the terminal has NO shell of
* its own: an explicit choice is never overwritten.
*/
autoTerminalShell: Schema.boolean().default(true).volatile(),
/**
* Dedupe switch against dsh-ptc-cordis-preset (their issue #7, v0.25.0):
* ON while that plugin is installed AND materializing its `PTC 创造模式`
* against Git Bash drops OUR `创造模式 · Git Bash` from the roster, because
* the two describe the same mode. Default OFF — the historical four-variant
* roster is unchanged until asked for. The same state is rendered on their
* settings card too (their card binds this row through configForms), so
* changing it on either side changes it here.
*/
suppressPeerCordis: Schema.boolean().default(false).volatile(),
/**
* Serve the four `* · Git Bash` VARIANT presets (v0.34.x behaviour) instead
* of relying on the inline tool patch (v0.35.0). Default OFF for every user
* — new and existing — per the owner's explicit call: the official presets
* carry Git Bash through the per-agent tool patch, the roster stays the
* official four, and the variants remain one switch away for anyone who
* wants the duplicated roster back. OFF also stops the reconcile from
* registering variants; a session pinned to a variant id must re-select an
* official preset (the roster flip is announced in the CHANGELOG).
*/
variantPresets: Schema.boolean().default(false).volatile(),
/**
* Show the host `pwsh` tool to agents (v0.39.0 semantics; default OFF).
* Route B keeps the official pwsh executor in place, so the tool is REAL
* PowerShell (7 first per the official resolver) — showing it is a pure
* product choice. OFF (default) hides it per agent: the host stays
* bash-first. The retired v0.37 `pwsh7` side-channel is gone; this switch
* now controls the real thing.
*/
pwshTool: Schema.boolean().default(false).volatile(),
// Maintainer PR #26 decision: one live opt-out for refusal and companions.
experimentalNative: Schema.boolean().default(true).volatile(),
/**
* Publish the FastCtx file tools (`gb_*`) through the local MCP bridge.
* Default OFF (dsh-ops 0.3.0 supersedes it); Windows-only payload, and it
* stands down entirely when dsh-ops is present.
*/
fastctxTools: Schema.boolean().default(false).volatile(),
})
/** Read one Config value: a Volatile ref, or a plain value from a test. */
export function valueOf(value) {
return value && typeof value.get === 'function' ? value.get() : value
}
/**
* This row's OWN live `bashPath` (issue #13): apply()'s `config` parameter IS
* the `gitbash-shell` row's resolved Config, so reading it here replaces the
* settings-service detour (`settingsBashPath`) that could read '' at apply
* time on hosts where the service is describe()/update()-only or not injected
* yet. Volatile refs mean a settings edit lands on the next read, no listener.
* @param {object|undefined} config - apply()'s resolved Config (may be absent).
* @returns {string} the row's `bashPath`, '' when unset or unreadable.
*/
export function rowBashPath(config) {
try {
const value = config && typeof config === 'object' ? valueOf(config.bashPath) : undefined
return typeof value === 'string' ? value.trim() : ''
} catch {
return ''
}
}
/**
* Live settings reader for everything wired inside apply(): it reads this
* plugin's own Config refs (no listener needed — every consumer here already
* re-reads per dispatch/assembly).
* @param {object} ctx - the plugin's mounting context (kept for the call shape).
* @param {object|undefined} config - apply()'s resolved Config.
* @returns {{ dialect: () => object, posix: () => boolean, adoptSidebar: () => boolean, suppressPeerCordis: () => boolean }}
*/
function makeLiveReader(ctx, config) {
const hasConfig = !!(config && typeof config === 'object')
const dialect = () => {
if (!hasConfig) {
// No row Config (schemastery unresolvable): everything off except the
// opt-out booleans.
return {
posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false,
codePaths: false, gitAutocrlf: false, bashPath: '', subagentDialect: true, autoTerminalShell: true,
}
}
const bash = valueOf(config.bashPath)
return {
posixPaths: valueOf(config.posixPaths) === true,
virtualMounts: valueOf(config.virtualMounts) === true,
globSplit: valueOf(config.globSplit) === true,
errorDialect: valueOf(config.errorDialect) === true,
codePaths: valueOf(config.codePaths) === true,
gitAutocrlf: valueOf(config.gitAutocrlf) === true,
// Default ON: delegated agents keep the dialect unless the user opts out.
subagentDialect: valueOf(config.subagentDialect) !== false,
bashPath: typeof bash === 'string' ? bash : '',
adoptSidebar: valueOf(config.adoptSidebar) !== false,
// Default ON (v0.29.0): the official terminal is ours unless opted out.
autoTerminalShell: valueOf(config.autoTerminalShell) !== false,
// Default OFF (v0.35.0): the official presets carry Git Bash through the
// inline tool patch; the variant roster is opt-in.
variantPresets: valueOf(config.variantPresets) === true,
// Default OFF (v0.39.0): route B keeps the real pwsh tool hidden unless
// the user explicitly shows it.
pwshTool: valueOf(config.pwshTool) === true,
// Default OFF (v0.39.4, dsh-ops 0.3.0): FastCtx is dead upstream and dsh-ops ships stronger native ops_* file tools; an explicit true re-enables the bridge. It still stands down when dsh-ops is present.
fastctxTools: valueOf(config.fastctxTools) === true,
}
}
return {
dialect,
posix: () => dialect().posixPaths === true,
adoptSidebar: () => dialect().adoptSidebar !== false,
autoTerminalShell: () => dialect().autoTerminalShell !== false,
variantPresets: () => (hasConfig ? valueOf(config.variantPresets) === true : false),
pwshTool: () => (hasConfig ? valueOf(config.pwshTool) === true : false),
experimentalNative: () => (hasConfig ? valueOf(config.experimentalNative) !== false : true),
fastctxTools: () => (hasConfig ? valueOf(config.fastctxTools) === true : false),
// Default OFF: only an explicit true asks for deduplication.
suppressPeerCordis: () => (hasConfig ? valueOf(config.suppressPeerCordis) === true : false),
}
}
// ── dsh-better-sidebar shell cooperation ───────────────────────────────────
//
// dsh-better-sidebar (v0.15.2+) resolves its terminal shell PER OPEN: a
// settings-page `terminalShell` value wins over the yaml/plugin default
// (their own words: "values here win for terminals opened afterwards").
// So this plugin adopts that official runtime seam — zero upstream change,
// no restart needed — and points the sidebar's UI terminal tabs AND the
// model-facing terminal_* tools at Git Bash on Windows. The plugin TAKES
// OVER the pref unconditionally while installed (a value set elsewhere is
// overwritten at every boot — that is the contract); the previous value is
// captured and restored on disposal (reload/update/uninstall), so removing
// this plugin returns the sidebar to what it was before.
// The settings namespace is the row id in dsh-better-sidebar's own bundle
// patch (`- id: better-sidebar`; verified against the published 0.25.0
// tarball) — NOT the npm package name. Using the package name made
// settings.describe() never match and every takeover silently no-op with one
// "unavailable" log line (issue #25, Bug 3).
const SIDEBAR_NS = 'better-sidebar'
const SIDEBAR_TRIES = 12
const SIDEBAR_RETRY_MS = 1500
/**
* Reconcile the better-sidebar `terminalShell` takeover with the adoptSidebar
* setting (v0.15.0, default ON). ON writes our bashPath (remembering what was
* there before); OFF restores that previous value — but only while the current
* value is still ours, so a user's manual choice is never clobbered. The
* polling keeps the original ready-wait behavior: at boot the settings
* service may not be provided yet when this row's apply runs. Never throws.
*
* `readAdopt` is apply()'s live gate getter (`() => liveSettings.
* adoptSidebar()`), passed in because this module-level helper cannot see
* that scope-local reader itself (v0.24.2 regression fix — referencing
* `liveSettings` here threw `ReferenceError: liveSettings is not defined`
* from `run()` and aborted the whole plugin mount).
*/
function adoptSidebarShell(ctx, bashPath, readAdopt) {
let adopted = false
let previous = ''
let tried = 0
let timer = null
let closed = false
let serial = Promise.resolve()
const readShell = () => {
const settings = ctx.get('settings')
if (!settings || typeof settings.update !== 'function') return null
try {
if (typeof settings.describe !== 'function') return null
const entry = settings.describe().find((row) => row && row.ns === SIDEBAR_NS)
const value = entry?.value
return value && typeof value.terminalShell === 'string' ? value.terminalShell : ''
} catch { return null }
}
const restore = async () => {
if (!adopted) return
const now = readShell()
if (now === null) return
// The user may have selected another shell since our takeover.
if (now === bashPath) {
await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: previous })
console.log(TAG + " better-sidebar shell restored ('" + previous + "')")
}
adopted = false
}
const reconcileSidebar = async (desired) => {
const current = readShell()
if (current === null) return false
if (!desired) { await restore(); return true }
if (adopted || current === bashPath) return true
previous = current
await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: bashPath })
adopted = true
console.log(TAG + ' dsh-better-sidebar terminal shell -> Git Bash (' + bashPath + ')')
return true
}
const run = () => {
if (closed) return serial
if (timer) { clearTimeout(timer); timer = null }
serial = serial.then(async () => {
if (closed) return
tried += 1
const ok = await reconcileSidebar(readAdopt())
if (ok || closed) return
if (tried < SIDEBAR_TRIES) timer = setTimeout(run, SIDEBAR_RETRY_MS)
else console.log(TAG + ' better-sidebar shell adoption skipped: settings unavailable')
}).catch((error) => {
console.log(TAG + ' better-sidebar shell adoption unavailable: ' + (error?.message ?? error))
})
return serial
}
try {
ctx.on('loader/volatile-update', (paths) => {
if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'adoptSidebar')) return
tried = 0
void run()
})
} catch { /* a diagnostic context may have no event service */ }
ctx.effect(() => async () => {
closed = true
if (timer) clearTimeout(timer)
await serial
await restore()
}, 'dsh-gitbash-shell: sidebar shell revert')
return run()
}
// ── plugin ──────────────────────────────────────────────────────────────────
// ── declarative registration ────────────────────────────────────────────────
//
// A preset is a definition registered through agentPresets.register(); dsh
// >=0.2.2-rc.1 has no directory-preset path. The composition data lives in
// src/compositions.js.
/** Resolve the Creation-skills dir beside @deepseek-ai/dsh-agent-preset. */
async function resolveSkillsDir() {
try {
const { createRequire } = await import('node:module')
const here = createRequire(import.meta.url)
const pkg = here.resolve('@deepseek-ai/dsh-agent-preset/package.json')
return join(dirname(pkg), 'skills')
} catch {
return undefined
}
}
/** The legacy preset root, best effort (used only to remove old leftovers). */
function legacyPresetRoot() {
try {
const env = process.env.DSH_HOME
if (env && env.trim() !== '') return join(env, '.agent-presets')
return join(homedir(), '.dsh', '.agent-presets')
} catch {
return undefined
}
}
/**
* Remove the stale materialized trees a previous (pre-0.1.7-host) version
* left behind, when they are still byte-identical to what we wrote (the
* marker judges). User-modified and foreign trees stay untouched — the same
* ownership rules the materializer itself follows.
*/
function cleanupLegacyTrees(presetIds) {
const root = legacyPresetRoot()
if (!root) return
for (const presetId of presetIds) {
const target = join(root, presetId)
const state = classify(target)
if (state === 'unmodified') {
try {
rmSync(target, { recursive: true, force: true })
console.log(TAG + ' removed the stale materialized preset at ' + target + ' (directory presets are no longer read on this host; the declarative registration replaces it)')
} catch (error) {
console.log(TAG + ' stale preset cleanup failed: ' + (error && error.message ? error.message : error))
}
} else if (state === 'user-modified') {
console.log(TAG + ' a user-modified preset tree remains at ' + target + ' — left untouched (this host ignores it; delete it manually if unwanted)')
}
}
}
/**
* Probe the dsh host additions for the variant compositions.
*
* `timeContext` / `toolSchedule` / `pluginMarket` / `compaction` are
* optional official rows (durable clock readings, reminder tools, the plugin
* market row, the minimal tool-fs + compaction delta). `profileContext` is the
* live host service the manager/market rows gate on — read from ctx, not from
* the package set.
*
* A preset row whose package is absent rejects the WHOLE mount, so optional
* rows ride this probe: each is added only when its package resolves from
* the HOST's module base — `ctx.baseUrl`, the exact base
* `prepareProfileEntries` mounts preset rows from, so the probe can never
* disagree with the mounter. Probed once per boot; the profile's package set
* does not change under a running host. A missing core row is not masked here:
* the 0.2.2-rc.1 base rows (tool-executor-*, agent-preset, …) are emitted
* unconditionally, so an inconsistent host fails the mount loudly.
* @param {object} ctx - cordis context (any fiber of the running host).
* @param {Function} [resolve] - predicate override for tests; receives a
* package specifier, resolves like `require.resolve`, returns boolean.
* @returns {Promise<{timeContext: boolean, toolSchedule: boolean,
* pluginMarket: boolean, compaction: boolean, profileContext: boolean}>}
*/
export async function probeHostExtras(ctx, resolve) {
let ok = resolve
if (ok === undefined) {
try {
const { createRequire } = await import('node:module')
const req = createRequire(ctx?.baseUrl ?? import.meta.url)
ok = (specifier) => {
try { req.resolve(specifier); return true } catch { return false }
}
} catch {
ok = () => false
}
}
const [timeContext, toolSchedule, pluginMarket, compaction] = await Promise.all([
Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-time-context')).then(Boolean),
Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-tool-schedule')).then(Boolean),
Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-plugin-market/tools')).then(Boolean),
Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-compaction-basic')).then(Boolean),
])
let profileContext = false
try {
profileContext = ctx?.get?.('profileContext') !== undefined
} catch {
profileContext = false
}
return { timeContext, toolSchedule, pluginMarket, compaction, profileContext }
}
/** Run one probe predicate; ANY failure reads as "absent" (a throwing
* resolver must degrade the row away, never reject the boot). */
function safeProbe(ok, specifier) {
try {
return ok(specifier)
} catch {
return false
}
}
/**
* The minimal variant's PTY row (`terminal-bash`) pins the shell binary it
* spawns; before issue #15 that was the one hardcoded `DEFAULT_GIT_BASH`
* copy left in the tree while every other consumer had moved to the
* resolver, so a Git installed outside `C:/Program Files/Git` failed every
* PTY spawn with node-pty's "File not found". The row now rides the ONE
* resolution chain (explicit config tier first — readConfiguredBashPath,
* the same chain the executor runs), read LAZILY per registration
* (issue #13: never frozen at apply time). An unresolved chain returns
* `undefined` and the composition then pins the historical default: per the
* two hard rules a missing Git Bash stays a VISIBLE spawn failure — never a
* substitute shell.
* @param {() => object} [readResolution] - the lazy resolution reader shared
* with the executor and the capability service.
* @returns {string|undefined} the resolved Git Bash path, or `undefined`
* when unresolved (the composition falls back to the visible default).
*/
export function minimalPtyBashPath(readResolution) {
try {
const resolution = readResolution()
return resolution && resolution.ok && resolution.path ? resolution.path : undefined
} catch {
return undefined
}
}
/**
* Register one variant definition; returns the unregister function. Mount
* failures stay visible through the roster's broken diagnostic instead of
* failing the boot.
*/
async function migrateVariantDefault(ctx, readVariants) {
if (readVariants() === true) return true
const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } }
const current = readDefault()
if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return true
const target = VARIANT_DEFAULT_SUCCESSORS[current]
try {
const settings = ctx.get('settings')
if (!settings || typeof settings.update !== 'function') return false
const roster = await ctx.agentPresets.list()
if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) {
console.warn(TAG + ' default-preset migration stood down: successor "' + target + '" is not registered. Set an OFFICIAL default or turn variantPresets on.')
return false
}
// A roster read is asynchronous: never overwrite a newer user selection.
if (readVariants() === true || readDefault() !== current) return true
await settings.update('agent-preset-registry', { selectedDefault: target })
const after = readDefault()
if (after !== target) {
console.warn(TAG + ' default-preset migration did not stick (read back "' + after + '"). Set an OFFICIAL default or turn variantPresets on.')
return false
}
console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target
+ '" (variantPresets is off). Pinned sessions are unchanged; agent-preset/locked still requires a NEW session.')
return true
} catch (error) {
console.warn(TAG + ' default-preset migration failed: ' + (error?.message ?? error)
+ '. Set an OFFICIAL default or turn variantPresets on; variants were not retired.')
return false
}
}
async function registerVariant(ctx, presetId, { skillsDir, pythonActive, hostExtras = {}, readBashResolution }) {
const meta = PRESET_META[presetId]
if (!meta) return undefined
const plugins = meta.kind === 'minimal'
? minimalPluginsFor({ bashPath: minimalPtyBashPath(readBashResolution), hostExtras })
: pluginsFor({ kind: meta.kind, skillsDir, pythonActive, hostExtras })
return ctx.agentPresets.register({
id: presetId,
name: meta.name,
description: meta.description,
order: meta.order,
plugins,
})
}
/**
* Declarative-era wiring: keep exactly one registration per VARIANT THIS HOST
* SHOULD SERVE for the plugin's lifetime, and re-reconcile whenever that set
* changes. Three inputs drive it — the live `suppressPeerCordis` switch, the
* peer capability appearing (or leaving) via `ctx.inject`, which fires
* independent of row activation order, and the peer's EFFECTIVE CPython
* backend (`pythonBackend`, falling back to the `pythonRuntime` intent on a
* peer that does not report one), which changes the ROWS of every variant —
* and the peer's own coverage only counts while it reports a Git Bash-
* materialized preset.
*
* The roster picks entries up immediately; sessions already pinned to a
* variant keep their revision until recomposed, so suppressing a variant never
* rewrites a running session.
* @param {object} ctx - the plugin's mounting context.
* @param {string[]} presetIds - the row's configured variants.
* @param {() => boolean} readSuppress - live dedupe switch reader.
* @param {() => object} readBashResolution - lazy Git Bash resolution reader
* (the chain shared with the executor and the capability); the minimal
* variant's PTY row reads it once per registration (issue #15).
* @returns {Promise<void>} resolves once the first reconcile finished.
*/
async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolution, readVariants) {
cleanupLegacyTrees(presetIds)
const skillsDir = await resolveSkillsDir()
// Optional official rows (time-context / tool-schedule / plugin-market /
// minimal compaction) probe once per boot and ride every (re-)registration
// below — the profile's package set cannot change under a running host.
const hostExtras = await probeHostExtras(ctx)
/** presetId -> unregister, i.e. exactly what is live right now. */
const live = new Map()
let peerGitBash = false
let peerPython = false
/** The CPython fact the LIVE registrations were built with. */
let registeredPython = false
let serial = Promise.resolve()
let closed = false
ctx.effect(() => async () => {
closed = true
await serial
const failures = []
for (const [presetId, off] of [...live]) {
live.delete(presetId)
try { await off() } catch (error) { failures.push(error) }
}
if (failures.length) throw new AggregateError(failures, 'Git Bash preset cleanup failed')
}, 'dsh-gitbash-shell: declarative preset registrations')
// v0.35.0: the variant roster is opt-in (`variantPresets`, default OFF).
// OFF means an EMPTY desired set — the cleanup above still runs, so a
// legacy materialized tree never survives just because the switch is off.
const desired = () => (readVariants && readVariants() === true
? effectivePresetIds(presetIds, { suppress: readSuppress(), peerGitBash })
: [])
const reconcile = () => {
if (closed) return serial
serial = serial.then(async () => {
if (closed) return
const want = desired()
const pythonActive = peerPython
// Dynamic OFF repairs the deployment default BEFORE retiring its id.
// Keep registrations if the official migration cannot be verified.
if (live.size > 0 && readVariants() !== true
&& !await migrateVariantDefault(ctx, readVariants)) return
if (closed) return
// The peer's EFFECTIVE CPython backend rewrites the workflow rows of
// EVERY variant (the official Python composition disables them, and
// workflow-ptc refuses a non-TypeScript backend), so a flip re-registers
// the live variants instead of leaving stale rows mounted. Same cadence
// as the backend swap itself: the peer's patch is evaluated at boot. A
// bare INTENT without the effective backend changes nothing here — the
// composition still runs Node, so the workflow rows must stay live.
if (live.size > 0 && registeredPython !== pythonActive) {
for (const [presetId, off] of [...live]) {
live.delete(presetId)
try {
await off()
console.log(TAG + " preset '" + presetId + "' retired (peer CPython backend changed; rows are rebuilt)")
} catch (error) {
live.set(presetId, off)
console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error))
return // never register replacements after an unverified retirement
}
}
}
for (const [presetId, off] of [...live]) {
if (want.includes(presetId)) continue
live.delete(presetId)
try {
await off()
console.log(TAG + " preset '" + presetId + "' retired (dsh-ptc-cordis-preset covers Creation mode on Git Bash)")
} catch (error) {
live.set(presetId, off)
console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error))
return
}
}
for (const presetId of want) {
if (closed) break
if (live.has(presetId)) continue
try {
const unregister = await registerVariant(ctx, presetId, { skillsDir, pythonActive, hostExtras, readBashResolution })
if (unregister) {
if (closed) { await unregister(); break }
live.set(presetId, unregister)
console.log(TAG + " preset '" + presetId + "' registered declaratively")
}
} catch (error) {
console.log(TAG + " preset '" + presetId + "' registration failed: " + (error && error.message ? error.message : error))
}
}
registeredPython = pythonActive
}).catch((error) => {
console.log(TAG + ' preset reconcile failed: ' + (error && error.message ? error.message : error))