@ddtcorex/dsh-maestro-config 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- // Verbatim legacy-card port (fields/styles preserved; see maestro-card.tsx).
2
- import { MaestroSettingsTab } from './maestro-card.jsx'
1
+ // DSH-native redesign reuses DSH tokens & primitive geometry maximally (see MaestroSettings.tsx).
2
+ import { MaestroSettingsTab } from './MaestroSettings.js'
3
3
  import { MAESTRO_RPC_CHANNEL } from './api.js'
4
4
  import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from './settings-nav-icon.js'
5
5
 
@@ -7,12 +7,13 @@ import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from './settings-nav-ico
7
7
  * DSH 0.1.x gives external settings sections a generic gear and exposes no
8
8
  * icon field in the settings.section contract (mirrors dsh-better-sidebar):
9
9
  * the marker only claims this plugin's localized row and this CSS paints the
10
- * Lucide "audio-lines" glyph as a currentColor mask so it follows native nav
11
- * hover/active colors at the shell's 16px icon rhythm.
10
+ * Maestro M-logo glyph as a currentColor mask so it follows native nav
11
+ * hover/active colors at the shell's 16px icon rhythm. The path matches the
12
+ * sidebar MaestroTrigger (trigger.tsx#MaestroLogo) — M2 11 L5 4 L8 9 L11 4 L14 11.
12
13
  */
13
14
  const SETTINGS_NAV_CSS = `
14
15
 
15
- /* maestro: replace the settings-nav fallback gear with the maestro glyph */
16
+ /* maestro: replace the settings-nav fallback gear with the Maestro M-logo glyph */
16
17
  [${SETTINGS_NAV_MARKER}] > svg:first-child {
17
18
  display: none;
18
19
  }
@@ -23,8 +24,8 @@ const SETTINGS_NAV_CSS = `
23
24
  width: 16px;
24
25
  height: 16px;
25
26
  background: currentColor;
26
- -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 10v3'/%3E%3Cpath d='M6 4v16'/%3E%3Cpath d='M10 8v8'/%3E%3Cpath d='M14 4v16'/%3E%3Cpath d='M18 6v12'/%3E%3Cpath d='M22 10v3'/%3E%3C/svg%3E") center / contain no-repeat;
27
- mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 10v3'/%3E%3Cpath d='M6 4v16'/%3E%3Cpath d='M10 8v8'/%3E%3Cpath d='M14 4v16'/%3E%3Cpath d='M18 6v12'/%3E%3Cpath d='M22 10v3'/%3E%3C/svg%3E") center / contain no-repeat;
27
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 11 L5 4 L8 9 L11 4 L14 11'/%3E%3C/svg%3E") center / contain no-repeat;
28
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 11 L5 4 L8 9 L11 4 L14 11'/%3E%3C/svg%3E") center / contain no-repeat;
28
29
  }
29
30
  `
30
31
 
@@ -42,6 +43,8 @@ interface ClientCtx {
42
43
  effect(fn: () => () => void, label?: string): unknown
43
44
  }
44
45
 
46
+ export const inject = ['slots', 'connection'] as const
47
+
45
48
  function installNavIconStyle(): () => void {
46
49
  const tag = document.createElement('style')
47
50
  tag.dataset.plugin = '@ddtcorex/dsh-maestro-config'
@@ -86,6 +86,32 @@ const codeStyle = {
86
86
  wordBreak: 'break-all',
87
87
  }
88
88
 
89
+ const textareaStyle = {
90
+ ...inputStyle,
91
+ height: 120,
92
+ padding: '8px 12px',
93
+ resize: 'vertical',
94
+ }
95
+
96
+ const tabBarStyle = {
97
+ display: 'flex',
98
+ gap: 8,
99
+ borderBottom: '1px solid var(--dsw-alias-border-l2)',
100
+ marginBottom: 16,
101
+ }
102
+
103
+ const tabButtonStyle = (active) => ({
104
+ padding: '8px 14px',
105
+ border: 'none',
106
+ borderBottom: active ? '2px solid var(--dsw-alias-button-primary-fill)' : '2px solid transparent',
107
+ background: 'transparent',
108
+ color: active ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
109
+ font: 'inherit',
110
+ fontSize: 13,
111
+ fontWeight: active ? 600 : 400,
112
+ cursor: 'pointer',
113
+ })
114
+
89
115
  /** QR code centered in a light tile with an even scanner-friendly quiet zone. */
90
116
  function QrImage({ url, size = 104 }) {
91
117
  const [dataUrl, setDataUrl] = useState(null)
@@ -373,26 +399,6 @@ function ToggleField({ label, caption, checked, onChange }) {
373
399
  )
374
400
  }
375
401
 
376
- /** Newest-first list of recorded review runs from the host's reviews.json. */
377
- function ReviewHistoryPanel({ rpcCall }) {
378
- const [entries, setEntries] = useState(null)
379
- useEffect(() => {
380
- rpcCall(MAESTRO_ENDPOINTS.reviewsList, {})
381
- .then(res => { if (res?.ok) setEntries(res.value ?? []) })
382
- .catch(() => setEntries([]))
383
- }, [])
384
- if (entries === null) return h('p', { style: captionStyle }, 'Loading review history…')
385
- if (entries.length === 0) return h('p', { style: captionStyle }, 'No reviews recorded yet.')
386
- const icon = entry => entry.status === 'completed' ? '✅' : entry.status === 'failed' ? '⚠️' : '👀'
387
- return h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } },
388
- entries.map(entry => h('li', { key: entry.id, style: { padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-separator-default, #333)', fontSize: 13 } },
389
- h('span', null, `${icon(entry)} ${entry.projectPath} !${entry.mrIid} · ${entry.mode}${entry.trigger !== 'mention' ? ` · ${entry.trigger}` : ''}`),
390
- h('div', { style: captionStyle },
391
- `${new Date(entry.startedAt).toLocaleString()}${entry.summary ? ` — ${entry.summary}` : ''}${entry.error ? ` — ${entry.error}` : ''}`),
392
- )),
393
- )
394
- }
395
-
396
402
  /** One selectable LAN address chip + the QR of the currently selected URL. */
397
403
  function LanAccess({ proxyStatus, lanPin }) {
398
404
  const urls = proxyStatus?.lanUrls ?? []
@@ -492,6 +498,13 @@ export function MaestroSettingsTab({ rpcCall, configRpcCall }) {
492
498
  const [lanPinEnabled, setLanPinEnabled] = useState(false)
493
499
  const [lanPin, setLanPin] = useState(null)
494
500
  const [showLanPin, setShowLanPin] = useState(false)
501
+ // Task 3: Guard/Blacklist/Supervisor/Notifier tabs state
502
+ const [activeTab, setActiveTab] = useState('guard')
503
+ const [guard, setGuard] = useState({})
504
+ const [patternsText, setPatternsText] = useState('')
505
+ const [placeholdersText, setPlaceholdersText] = useState('')
506
+ const [supervisorCfg, setSupervisorCfg] = useState({})
507
+ const [notifierCfg, setNotifierCfg] = useState({})
495
508
 
496
509
  const call = async (endpoint, payload) => {
497
510
  const res = await rpcCall(endpoint, payload)
@@ -499,6 +512,60 @@ export function MaestroSettingsTab({ rpcCall, configRpcCall }) {
499
512
  return res.value
500
513
  }
501
514
 
515
+ // Helpers for guard/supervisor/notifier domains via generic config RPC (Task 3)
516
+ const unwrap = (res) => {
517
+ if (res && typeof res === 'object' && 'ok' in res) {
518
+ if (res.ok) return res.value
519
+ throw new Error(res.error?.message ?? 'RPC failed')
520
+ }
521
+ return res
522
+ }
523
+ const cfgGet = async (domain) => {
524
+ if (!configRpcCall) throw new Error('config RPC not available')
525
+ const res = await configRpcCall('get', { domain })
526
+ return unwrap(res)
527
+ }
528
+ const cfgSet = async (domain, patch) => {
529
+ if (!configRpcCall) throw new Error('config RPC not available')
530
+ const res = await configRpcCall('set', { domain, patch })
531
+ return unwrap(res)
532
+ }
533
+ const saveGuard = async (patch) => {
534
+ setError(null)
535
+ const next = { ...guard, ...patch }
536
+ if (patch.gitProtection && guard.gitProtection) next.gitProtection = { ...guard.gitProtection, ...patch.gitProtection }
537
+ setGuard(next)
538
+ try { await cfgSet('guard', patch) } catch (e) { setError(e.message ?? String(e)) }
539
+ }
540
+ const commitBlacklistPatterns = async (text) => {
541
+ const patterns = text.split('\n').map(s => s.trim()).filter(Boolean)
542
+ setError(null)
543
+ try { await cfgSet('guardBlacklist', { patterns }) } catch (e) { setError(e.message ?? String(e)) }
544
+ }
545
+ const commitPlaceholders = async () => {
546
+ setError(null)
547
+ let obj = {}
548
+ try { obj = placeholdersText.trim() ? JSON.parse(placeholdersText) : {}; if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) throw new Error('placeholders must be JSON object') } catch (e) { setError(`placeholders JSON invalid: ${e.message ?? String(e)}`); return }
549
+ try { await cfgSet('guardBlacklist', { placeholders: obj }) } catch (e) { setError(e.message ?? String(e)) }
550
+ }
551
+ const saveSupervisorCfg = async (patch) => {
552
+ setError(null)
553
+ setSupervisorCfg(prev => ({ ...prev, ...patch }))
554
+ try { await cfgSet('supervisor', patch) } catch (e) { setError(e.message ?? String(e)) }
555
+ }
556
+ const saveNotifierCfg = async (patch) => {
557
+ setError(null)
558
+ setNotifierCfg(prev => {
559
+ const next = { ...prev }
560
+ for (const [k, v] of Object.entries(patch)) {
561
+ if (k === 'telegram' && typeof v === 'object' && v !== null) next.telegram = { ...(prev.telegram ?? {}), ...v }
562
+ else next[k] = v
563
+ }
564
+ return next
565
+ })
566
+ try { await cfgSet('notifier', patch) } catch (e) { setError(e.message ?? String(e)) }
567
+ }
568
+
502
569
  const refresh = async () => {
503
570
  try { setStatus(await call(MAESTRO_ENDPOINTS.status, {})) } catch { /* transient failure, ignore */ }
504
571
  try { setProxyStatus(await call(MAESTRO_ENDPOINTS.proxyStatus, {})) } catch { /* proxy row may be starting */ }
@@ -520,6 +587,21 @@ export function MaestroSettingsTab({ rpcCall, configRpcCall }) {
520
587
  }
521
588
  })
522
589
  .catch(() => { /* supervisor domain not yet set or config service unavailable */ })
590
+ // Task 3: load Guard/Blacklist/Supervisor/Notifier domains
591
+ Promise.all([
592
+ cfgGet('guard').catch(() => ({})),
593
+ cfgGet('guardBlacklist').catch(() => ({ patterns: [], placeholders: {} })),
594
+ cfgGet('supervisor').catch(() => ({})),
595
+ cfgGet('notifier').catch(() => ({})),
596
+ ]).then(([g, bl, sup, not]) => {
597
+ setGuard(g ?? {})
598
+ const pats = Array.isArray(bl?.patterns) ? bl.patterns : []
599
+ const ph = bl?.placeholders && typeof bl.placeholders === 'object' ? bl.placeholders : {}
600
+ setPatternsText(pats.join('\n'))
601
+ setPlaceholdersText(JSON.stringify(ph, null, 2))
602
+ setSupervisorCfg(sup ?? {})
603
+ setNotifierCfg(not ?? {})
604
+ }).catch(() => {})
523
605
  }
524
606
  call(MAESTRO_ENDPOINTS.lanPinStatus, {})
525
607
  .then(value => { setLanPinEnabled(value.enabled); if (value.enabled) setLanPin(value.pin ?? null) })
@@ -720,7 +802,6 @@ export function MaestroSettingsTab({ rpcCall, configRpcCall }) {
720
802
  checked: config.autoRereviewOnPush,
721
803
  onChange: checked => saveField('autoRereviewOnPush', checked),
722
804
  }),
723
- h(ReviewHistoryPanel, { rpcCall }),
724
805
  ),
725
806
 
726
807
  h('div', { style: sectionStyle },
@@ -754,6 +835,129 @@ export function MaestroSettingsTab({ rpcCall, configRpcCall }) {
754
835
  h(ProjectMappingsEditor, { mappings: config.projectMappings ?? [], onChange: mappings => saveField('projectMappings', mappings), catalog, globalReviewModel: config.reviewModel ?? null }),
755
836
  ),
756
837
 
838
+ // Task 3: Guard/Blacklist/Supervisor/Notifier tabs — data-driven over guard domains
839
+ h('div', { style: sectionStyle },
840
+ h('h4', { style: headingStyle }, 'Guard / Blacklist / Supervisor / Notifier'),
841
+ h('div', { style: tabBarStyle },
842
+ h('button', { type: 'button', style: tabButtonStyle(activeTab === 'guard'), onClick: () => setActiveTab('guard') }, 'Guard'),
843
+ h('button', { type: 'button', style: tabButtonStyle(activeTab === 'blacklist'), onClick: () => setActiveTab('blacklist') }, 'Blacklist'),
844
+ h('button', { type: 'button', style: tabButtonStyle(activeTab === 'supervisor'), onClick: () => setActiveTab('supervisor') }, 'Supervisor'),
845
+ h('button', { type: 'button', style: tabButtonStyle(activeTab === 'notifier'), onClick: () => setActiveTab('notifier') }, 'Notifier'),
846
+ ),
847
+ activeTab === 'guard' && h('div', { 'data-tab': 'guard' },
848
+ h('p', { style: captionStyle }, 'Enforce publish block, git protection and cwd containment.'),
849
+ h(ToggleField, {
850
+ label: 'publishBlocked',
851
+ caption: 'Block publish-related commands when enabled.',
852
+ checked: guard.publishBlocked === true,
853
+ onChange: v => saveGuard({ publishBlocked: v }),
854
+ }),
855
+ h(ToggleField, {
856
+ label: 'gitProtection.enabled',
857
+ caption: 'Protect pushes to protected branches.',
858
+ checked: guard.gitProtection?.enabled === true,
859
+ onChange: v => saveGuard({ gitProtection: { enabled: v, branches: guard.gitProtection?.branches ?? ['master', 'main'] } }),
860
+ }),
861
+ h('label', { style: fieldLabelStyle }, 'gitProtection.branches (comma separated)'),
862
+ h('input', {
863
+ style: inputStyle,
864
+ value: (guard.gitProtection?.branches ?? ['master', 'main']).join(', '),
865
+ placeholder: 'master, main',
866
+ onChange: e => {
867
+ const branches = e.target.value.split(',').map(s => s.trim()).filter(Boolean)
868
+ saveGuard({ gitProtection: { enabled: guard.gitProtection?.enabled ?? true, branches } })
869
+ },
870
+ }),
871
+ h(ToggleField, {
872
+ label: 'cwdContainment',
873
+ caption: 'Contain file operations to the session cwd.',
874
+ checked: guard.cwdContainment === true,
875
+ onChange: v => saveGuard({ cwdContainment: v }),
876
+ }),
877
+ h('label', { style: fieldLabelStyle }, 'credentialPaths (comma separated)'),
878
+ h('input', {
879
+ style: inputStyle,
880
+ value: (guard.credentialPaths ?? []).join(', '),
881
+ placeholder: '~/.config/credentials.yaml, ~/.config/cloudflared',
882
+ onChange: e => {
883
+ const credentialPaths = e.target.value.split(',').map(s => s.trim()).filter(Boolean)
884
+ saveGuard({ credentialPaths })
885
+ },
886
+ }),
887
+ ),
888
+ activeTab === 'blacklist' && h('div', { 'data-tab': 'blacklist' },
889
+ h('p', { style: captionStyle }, 'One pattern per line. These are blocked from being committed or published.'),
890
+ h('label', { style: fieldLabelStyle }, 'patterns (one per line)'),
891
+ h('textarea', {
892
+ style: textareaStyle,
893
+ value: patternsText,
894
+ placeholder: 'example-project\nacme-shop',
895
+ onChange: e => setPatternsText(e.target.value),
896
+ onBlur: e => commitBlacklistPatterns(e.target.value),
897
+ }),
898
+ h('label', { style: fieldLabelStyle }, 'placeholders JSON'),
899
+ h('textarea', {
900
+ style: { ...textareaStyle, height: 90 },
901
+ value: placeholdersText,
902
+ placeholder: '{"example-project":"my-project"}',
903
+ onChange: e => setPlaceholdersText(e.target.value),
904
+ onBlur: () => commitPlaceholders(),
905
+ }),
906
+ h('p', { style: captionStyle }, 'Map blocked patterns to their placeholder suggestions.'),
907
+ h('button', { type: 'button', style: { ...secondaryButtonStyle, marginTop: 8 }, onClick: () => { commitBlacklistPatterns(patternsText); commitPlaceholders() } }, 'Save Blacklist'),
908
+ ),
909
+ activeTab === 'supervisor' && h('div', { 'data-tab': 'supervisor' },
910
+ h('p', { style: captionStyle }, 'Background daemon that auto-resumes crashed sessions.'),
911
+ h('label', { style: fieldLabelStyle }, 'intervalMs'),
912
+ h('input', {
913
+ type: 'number',
914
+ style: inputStyle,
915
+ value: supervisorCfg.intervalMs ?? '',
916
+ placeholder: '5000',
917
+ onChange: e => { const v = e.target.value === '' ? undefined : Number(e.target.value); saveSupervisorCfg({ intervalMs: v }) },
918
+ }),
919
+ h('label', { style: fieldLabelStyle }, 'downThreshold'),
920
+ h('input', {
921
+ type: 'number',
922
+ style: inputStyle,
923
+ value: supervisorCfg.downThreshold ?? '',
924
+ placeholder: '3',
925
+ onChange: e => { const v = e.target.value === '' ? undefined : Number(e.target.value); saveSupervisorCfg({ downThreshold: v }) },
926
+ }),
927
+ h(ToggleField, {
928
+ label: 'autoResumeEnabled',
929
+ caption: 'Automatically resume down sessions.',
930
+ checked: supervisorCfg.autoResumeEnabled === true,
931
+ onChange: v => saveSupervisorCfg({ autoResumeEnabled: v }),
932
+ }),
933
+ ),
934
+ activeTab === 'notifier' && h('div', { 'data-tab': 'notifier' },
935
+ h('p', { style: captionStyle }, 'Telegram notifications for Maestro events.'),
936
+ h('label', { style: fieldLabelStyle }, 'telegram.botToken'),
937
+ h('input', {
938
+ type: 'password',
939
+ autoComplete: 'off',
940
+ style: inputStyle,
941
+ value: notifierCfg.telegram?.botToken ?? '',
942
+ placeholder: '123456:ABC-DEF...',
943
+ onChange: e => saveNotifierCfg({ telegram: { botToken: e.target.value } }),
944
+ }),
945
+ h('label', { style: fieldLabelStyle }, 'telegram.chatId'),
946
+ h('input', {
947
+ style: inputStyle,
948
+ value: notifierCfg.telegram?.chatId ?? '',
949
+ placeholder: '-1001234567890',
950
+ onChange: e => saveNotifierCfg({ telegram: { chatId: e.target.value } }),
951
+ }),
952
+ h(ToggleField, {
953
+ label: 'telegram.reviewNotifications',
954
+ caption: 'Also notify about finished reviews.',
955
+ checked: notifierCfg.telegram?.reviewNotifications === true || notifierCfg.policy?.reviewNotifications === true,
956
+ onChange: v => saveNotifierCfg({ telegram: { reviewNotifications: v } }),
957
+ }),
958
+ ),
959
+ ),
960
+
757
961
  error && h('p', { style: errorStyle }, error),
758
962
  )
759
963
  }
package/src/host/index.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type {} from '@deepseek-ai/dsh-client-connection'
2
1
  import type { Context } from '@deepseek-ai/cordis'
3
- import type { RpcErrorDetailsMap, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
2
+
3
+ type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string; details: object } }
4
+ type RpcErrorDetailsMap = { 'bad-request': { issues: object[] } }
4
5
  import { createMaestroConfigService, type MaestroConfigService } from './service.ts'
5
6
 
6
7
  export const name = 'maestro-config'
@@ -11,6 +12,7 @@ const RPC_CHANNEL = '/dsh-maestro-config'
11
12
  declare module '@deepseek-ai/cordis' {
12
13
  interface Context {
13
14
  maestroConfig: MaestroConfigService
15
+ connection: { rpc: { handle: (channel: string, handler: (endpoint: string, payload: unknown) => Promise<RpcResult<unknown>>, opts?: unknown) => () => void } }
14
16
  }
15
17
  }
16
18
 
@@ -31,7 +33,13 @@ function fail(message: string): RpcResult<never> {
31
33
  }
32
34
  }
33
35
 
34
- /** Publish maestroConfig over the shared store + loopback RPC for clients. */
36
+ /**
37
+ * Publish maestroConfig over the shared store + loopback RPC for clients.
38
+ * Exposes guard/guardBlacklist/supervisor/notifier domains (Task 1 validators)
39
+ * via generic get/set — validation is delegated to the lib's domain validators.
40
+ * Host also handles '/dsh-maestro-config/get' and '/dsh-maestro-config/set'
41
+ * style calls through the single channel with endpoint dispatch.
42
+ */
35
43
  export function apply(ctx: Context): void {
36
44
  const svc = createMaestroConfigService()
37
45
  ctx.provide('maestroConfig', svc)
@@ -43,6 +51,7 @@ export function apply(ctx: Context): void {
43
51
  }
44
52
  if (endpoint === 'get') {
45
53
  if (typeof body.domain !== 'string') return fail('domain (string) is required')
54
+ // guard / guardBlacklist / supervisor / notifier are all valid domains here
46
55
  return ok(await svc.get(body.domain))
47
56
  }
48
57
  if (endpoint === 'set') {