dsh-ssh-workbench 1.6.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -23,6 +23,10 @@ window.__ModuleLoader__.load({
23
23
  // don't expose the primitives table.
24
24
  let primitives = null
25
25
  try { primitives = require('@deepseek-ai/dsh-client-ui-primitives') } catch { primitives = null }
26
+ // Portal the dock panel to document.body so the composer's stacking context
27
+ // cannot clip or cover it. Optional: fall back to inline when unavailable.
28
+ let ReactDOM = null
29
+ try { ReactDOM = require('react-dom') } catch { ReactDOM = null }
26
30
 
27
31
  // The merged SSH guide card remembers which page (server manager or remote
28
32
  // files) its main button should open; the chevron always offers both.
@@ -247,7 +251,6 @@ window.__ModuleLoader__.load({
247
251
  'overview.kind.server-update': '更新资源',
248
252
  'overview.kind.server-remove': '删除资源',
249
253
  'common.loading': '加载中…',
250
- 'tool.target': '切换远程目标',
251
254
  'tool.running': '执行中…',
252
255
  'tool.noOutput': '(无输出)',
253
256
  'tool.input': '参数',
@@ -265,8 +268,15 @@ window.__ModuleLoader__.load({
265
268
  'files.chunkIncomplete': '文件分块读取不完整',
266
269
  'settings.tabServers': '纳管资源',
267
270
  'settings.tabActivity': '操作日志',
268
- 'target.chip': '目标',
269
- 'target.chipTitle': '当前远程目标:{name}(命令在该资源上执行;点击打开远程工作台面板)',
271
+ 'target.popTitle': '远程执行',
272
+ 'target.popCount': '{n} 次',
273
+ 'target.rowCount': '{n} 次',
274
+ 'target.execChip': '远程执行 · {n} 次',
275
+ 'target.execTitle': '本会话远程执行次数',
276
+ 'target.runningOne': '{name} · 执行中',
277
+ 'target.runningMany': '{n} 个目标 · 执行中',
278
+ 'target.runningValue': '{n} 个目标执行中',
279
+ 'target.runningLabel': '执行中',
270
280
  'common.close': '关闭',
271
281
  }
272
282
 
@@ -465,7 +475,6 @@ window.__ModuleLoader__.load({
465
475
  'overview.kind.server-update': 'Resource updated',
466
476
  'overview.kind.server-remove': 'Resource removed',
467
477
  'common.loading': 'Loading…',
468
- 'tool.target': 'Switch remote target',
469
478
  'tool.running': 'Running…',
470
479
  'tool.noOutput': '(no output)',
471
480
  'tool.input': 'Input',
@@ -483,8 +492,15 @@ window.__ModuleLoader__.load({
483
492
  'files.chunkIncomplete': 'File chunk read was incomplete',
484
493
  'settings.tabServers': 'Managed resources',
485
494
  'settings.tabActivity': 'Activity log',
486
- 'target.chip': 'Target',
487
- 'target.chipTitle': 'Current remote target: {name} (commands run there; click to open SSH Workbench)',
495
+ 'target.popTitle': 'Remote executions',
496
+ 'target.popCount': '{n} runs',
497
+ 'target.rowCount': '{n} runs',
498
+ 'target.execChip': 'Remote · {n} runs',
499
+ 'target.execTitle': 'Remote executions in this session',
500
+ 'target.runningOne': '{name} · running',
501
+ 'target.runningMany': '{n} targets · running',
502
+ 'target.runningValue': '{n} targets running',
503
+ 'target.runningLabel': 'running',
488
504
  'common.close': 'Close',
489
505
  'files.transferUploading': 'Uploading {name}',
490
506
  'files.transferDownloading': 'Downloading {name}',
@@ -590,13 +606,19 @@ window.__ModuleLoader__.load({
590
606
  }
591
607
 
592
608
  /**
593
- * Current remote target in the composer dock (the small parameter row below
594
- * the composer). Styled like the shipped usage counters instead of a chip
595
- * button, so it visually belongs to that row.
609
+ * Composer dock for the Session's remote resources. The popover deliberately
610
+ * mirrors the shipped usage counter: one header value, a hairline divider,
611
+ * and a label/value grid. It is read-only: targets are shown with their
612
+ * plugin-local execution count for this Session.
596
613
  */
597
614
  function TargetDock(props) {
598
615
  const sessionId = props && props.sessionId
599
- const [target, setTarget] = useState(null)
616
+ const [stats, setStats] = useState(null)
617
+ const [running, setRunning] = useState(null)
618
+ const [open, setOpen] = useState(false)
619
+ const [rect, setRect] = useState(null)
620
+ const rootRef = useRef(null)
621
+ const panelRef = useRef(null)
600
622
  useEffect(() => {
601
623
  ensurePanelStyle()
602
624
  }, [])
@@ -606,56 +628,147 @@ window.__ModuleLoader__.load({
606
628
  const load = async () => {
607
629
  try {
608
630
  const body = await api('/target?session=' + encodeURIComponent(sessionId))
609
- if (alive) setTarget(body && body.target ? String(body.target) : null)
631
+ if (!alive) return
632
+ setStats(body && body.stats && typeof body.stats === 'object' ? body.stats : null)
633
+ setRunning(body && body.running && typeof body.running === 'object' ? body.running : null)
610
634
  } catch {
611
- if (alive) setTarget(null)
635
+ if (!alive) return
636
+ setStats(null)
637
+ setRunning(null)
612
638
  }
613
639
  }
614
640
  void load()
615
- const timer = setInterval(load, 4000)
641
+ // The dock is a read-only projection of the execution ledger. Tool
642
+ // events already trigger a refresh on the next turn; this low-frequency
643
+ // fallback keeps a long turn from showing stale counts.
644
+ const timer = setInterval(load, 1500)
616
645
  return () => {
617
646
  alive = false
618
647
  clearInterval(timer)
619
648
  }
620
649
  }, [sessionId])
621
- if (!target) return null
622
- const label = (() => {
623
- try {
624
- return String(translate('target.chip'))
625
- } catch {
626
- return 'Target'
650
+ useEffect(() => {
651
+ if (!open || typeof document === 'undefined') return undefined
652
+ const onPointerDown = (event) => {
653
+ const node = event.target
654
+ if (node && typeof node.closest === 'function' && node.closest('[data-sdh-target-dock]')) return
655
+ if (rootRef.current && node && rootRef.current.contains(node)) return
656
+ if (panelRef.current && node && panelRef.current.contains(node)) return
657
+ setOpen(false)
627
658
  }
628
- })()
629
- const title = (() => {
659
+ const onKeyDown = (event) => {
660
+ if (event.key === 'Escape') {
661
+ event.stopPropagation()
662
+ setOpen(false)
663
+ }
664
+ }
665
+ document.addEventListener('mousedown', onPointerDown, true)
666
+ document.addEventListener('keydown', onKeyDown, true)
667
+ return () => {
668
+ document.removeEventListener('mousedown', onPointerDown, true)
669
+ document.removeEventListener('keydown', onKeyDown, true)
670
+ }
671
+ }, [open])
672
+
673
+ const tr = (key, fallback) => {
630
674
  try {
631
- return String(translate('target.chipTitle')).replace('{name}', target)
675
+ const value = String(translate(key))
676
+ return value === key ? fallback : value
632
677
  } catch {
633
- return target
678
+ return fallback
634
679
  }
635
- })()
636
- return h('span', { className: 'dsh-ssh-workbench-target-anchor' },
637
- h('button', {
638
- type: 'button',
639
- className: 'dsh-ssh-workbench-target-pill',
640
- title,
641
- 'aria-label': title,
642
- onClick: () => {
643
- try {
644
- const layout = clientCtx && clientCtx.get('layout')
645
- if (layout && typeof layout.openRightbar === 'function') layout.openRightbar(true, false)
646
- } catch {
647
- // opening the panel is a convenience, not required
648
- }
649
- },
650
- },
651
- h('svg', {
652
- viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
653
- strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
680
+ }
681
+ const statsServers = stats && Array.isArray(stats.servers) ? stats.servers : []
682
+ const rows = statsServers.filter((bucket) => bucket && Number.isFinite(bucket.count) && bucket.count > 0)
683
+ const total = stats && Number.isFinite(stats.count) ? stats.count : 0
684
+ const runningServers = running && Array.isArray(running.servers) ? running.servers.filter((entry) => entry && entry.name) : []
685
+ const runningNames = [...new Set(runningServers.map((entry) => entry.name))]
686
+ // A session with no remote execution has no dock. While a call is in
687
+ // flight we still render: the live target is the point of the indicator.
688
+ if (rows.length === 0 && total === 0 && runningNames.length === 0) return null
689
+ const togglePanel = (event) => {
690
+ if (open) {
691
+ setOpen(false)
692
+ return
693
+ }
694
+ const bounds = event.currentTarget.getBoundingClientRect()
695
+ const viewportWidth = typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : 320
696
+ const viewportHeight = typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : 600
697
+ const right = Math.max(8, viewportWidth - bounds.right)
698
+ const bottom = Math.max(8, viewportHeight - bounds.top + 6)
699
+ setRect({ right, bottom })
700
+ setOpen(true)
701
+ }
702
+ const runningOne = runningNames.length === 1
703
+ const label = runningOne
704
+ ? tr('target.runningOne', '{name} · running').replace('{name}', runningNames[0])
705
+ : runningNames.length > 1
706
+ ? tr('target.runningMany', '{n} targets · running').replace('{n}', String(runningNames.length))
707
+ : tr('target.execChip', 'Remote · {n}').replace('{n}', String(total))
708
+ const title = runningNames.length > 0
709
+ ? runningOne
710
+ ? tr('target.runningOne', '{name} · running').replace('{name}', runningNames[0])
711
+ : tr('target.runningMany', '{n} targets · running').replace('{n}', String(runningNames.length))
712
+ : tr('target.execTitle', 'Remote executions in this session')
713
+ const icon = h('svg', {
714
+ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
715
+ strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
716
+ },
717
+ h('rect', { x: 3, y: 4, width: 18, height: 7, rx: 1.5 }),
718
+ h('rect', { x: 3, y: 13, width: 18, height: 7, rx: 1.5 }),
719
+ h('path', { d: 'M6.5 7.5h.01M6.5 16.5h.01M10 7.5h7M10 16.5h7' }))
720
+ const button = h('button', {
721
+ type: 'button',
722
+ className: 'dsh-ssh-workbench-target-pill',
723
+ title,
724
+ 'aria-label': title,
725
+ 'aria-haspopup': 'dialog',
726
+ 'aria-expanded': open ? 'true' : 'false',
727
+ onClick: togglePanel,
728
+ },
729
+ icon,
730
+ h('span', { className: 'dsh-ssh-workbench-target-label' }, label))
731
+
732
+ const rowByName = new Map(rows.map((bucket) => [bucket.name, bucket]))
733
+ const detailNames = [
734
+ ...runningNames,
735
+ ...rows.map((bucket) => bucket.name).filter((name) => !runningNames.includes(name)),
736
+ ]
737
+ const details = []
738
+ for (const name of detailNames) {
739
+ const bucket = rowByName.get(name)
740
+ const active = runningNames.includes(name)
741
+ const parts = []
742
+ if (active) parts.push(tr('target.runningLabel', 'running'))
743
+ if (bucket) parts.push(tr('target.rowCount', '{n} runs').replace('{n}', String(bucket.count)))
744
+ details.push(h('dt', { key: name + ':label' }, name))
745
+ details.push(h('dd', {
746
+ key: name + ':value',
747
+ ...(active ? { className: 'dsh-ssh-workbench-target-row-running' } : {}),
748
+ }, parts.join(' · ')))
749
+ }
750
+
751
+ const panel = open && rect
752
+ ? h('div', {
753
+ ref: panelRef,
754
+ className: 'dsh-ssh-workbench-target-popover',
755
+ role: 'dialog',
756
+ 'aria-label': tr('target.popTitle', 'Remote executions'),
757
+ 'data-sdh-target-dock': '',
758
+ style: { right: rect.right + 'px', bottom: rect.bottom + 'px' },
654
759
  },
655
- h('rect', { x: 3, y: 4, width: 18, height: 7, rx: 1.5 }),
656
- h('rect', { x: 3, y: 13, width: 18, height: 7, rx: 1.5 }),
657
- h('path', { d: 'M6.5 7.5h.01M6.5 16.5h.01M10 7.5h7M10 16.5h7' })),
658
- h('span', { className: 'dsh-ssh-workbench-target-label' }, label + ' ' + target)))
760
+ h('div', { className: 'dsh-ssh-workbench-target-title' },
761
+ h('span', { className: 'dsh-ssh-workbench-target-title-label' }, icon, tr('target.popTitle', 'Remote executions')),
762
+ h('span', { className: 'dsh-ssh-workbench-target-title-value' }, runningNames.length > 0
763
+ ? tr('target.runningValue', '{n} targets running').replace('{n}', String(runningNames.length))
764
+ : tr('target.popCount', '{n} runs').replace('{n}', String(total)))),
765
+ h('div', { className: 'dsh-ssh-workbench-target-rule', 'aria-hidden': true }),
766
+ h('dl', { className: 'dsh-ssh-workbench-target-details' }, ...details))
767
+ : null
768
+ const mountedPanel = panel && ReactDOM && typeof document !== 'undefined'
769
+ ? ReactDOM.createPortal(panel, document.body)
770
+ : panel
771
+ return h('span', { ref: rootRef, className: 'dsh-ssh-workbench-target-anchor', 'data-sdh-target-dock': '' }, button, mountedPanel)
659
772
  }
660
773
 
661
774
  /**
@@ -867,7 +980,18 @@ window.__ModuleLoader__.load({
867
980
  '@media (hover:hover){.dsh-ssh-workbench-target-pill:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}}',
868
981
  '.dsh-ssh-workbench-target-pill:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-1px}',
869
982
  '.dsh-ssh-workbench-target-label{min-width:0;overflow:hidden;text-overflow:ellipsis}',
870
- '.dsh-ssh-workbench-input{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:0 12px;min-width:0}',
983
+ '.dsh-ssh-workbench-target-popover{position:fixed;z-index:1100;box-sizing:border-box;width:max-content;min-width:min(300px,calc(100vw - 24px),var(--dsh-anchored-max-width,100vw));max-width:min(440px,calc(100vw - 24px),var(--dsh-anchored-max-width,100vw));max-height:var(--dsh-anchored-max-height,70vh);overflow:hidden auto;padding:16px;border:0;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu,var(--dsw-menu-surface-fill));backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;font-size:12px;line-height:18px;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}',
984
+ '.dsh-ssh-workbench-target-title{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}',
985
+ '.dsh-ssh-workbench-target-title-label{display:inline-flex;align-items:center;gap:6px;min-width:0}',
986
+ '.dsh-ssh-workbench-target-title-label svg{flex:none;width:14px;height:14px}',
987
+ '.dsh-ssh-workbench-target-title-value{font-variant-numeric:tabular-nums}',
988
+ '.dsh-ssh-workbench-target-rule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:10px}',
989
+ '.dsh-ssh-workbench-target-details{display:grid;grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;color:var(--dsw-alias-label-tertiary)}',
990
+ '.dsh-ssh-workbench-target-details dt,.dsh-ssh-workbench-target-details dd{min-width:0;margin:0}',
991
+ '.dsh-ssh-workbench-target-details dt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
992
+ '.dsh-ssh-workbench-target-details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}',
993
+ '.dsh-ssh-workbench-target-details dd.dsh-ssh-workbench-target-row-running{color:var(--dsw-alias-label-primary);font-weight:500}',
994
+ '.dsh-ssh-workbench-input{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:0 12px;min-width:0}',
871
995
  '.dsh-ssh-workbench-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
872
996
  '.dsh-ssh-workbench-input-compact{height:28px;padding:0 8px;border-radius:var(--dsw-radius-sm);border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);font-size:12px;line-height:18px}',
873
997
  '.dsh-ssh-workbench-select-trigger{display:flex;align-items:center;justify-content:space-between;gap:8px;text-align:left;cursor:pointer}',
@@ -2013,106 +2137,6 @@ window.__ModuleLoader__.load({
2013
2137
  : tr('tool.failed', '调用失败')
2014
2138
  }
2015
2139
 
2016
- function SshOpsToolRow(props) {
2017
- const phase = props?.phase ?? 'result'
2018
- const block = props?.block
2019
- const args = toolArgs(block)
2020
- const toolName = props?.toolName ?? block?.call?.name ?? 'ssh_workbench'
2021
- const command = typeof args?.command === 'string'
2022
- ? args.command
2023
- : typeof args?.commands === 'string'
2024
- ? args.commands
2025
- : typeof args?.pack === 'string'
2026
- ? args.pack
2027
- : typeof args?.text === 'string'
2028
- ? args.text
2029
- : ''
2030
- const target = Array.isArray(args?.servers)
2031
- ? args.servers.join(', ')
2032
- : args?.tag
2033
- ? 'tag:' + args.tag
2034
- : args?.server ?? args?.session ?? ''
2035
- const body = phase === 'result' ? textFromToolContent(block) : ''
2036
- const bodyText = body.trim()
2037
- const errorCode = phase === 'result' ? toolErrorCode(block) : ''
2038
- const failed = phase === 'result' && (block?.isError || block?.error)
2039
- const running = phase === 'preparing' || phase === 'start'
2040
- const [expanded, setExpanded] = useState(false)
2041
- const labels = {
2042
- ssh_workbench_target: tr('tool.target', '切换远程目标'),
2043
- }
2044
- const label = labels[toolName] ?? tr('tool.target', '切换远程目标')
2045
- const argsText = args ? JSON.stringify(args, null, 2) : ''
2046
- const canExpand = Boolean(bodyText || argsText)
2047
- const summary = failed
2048
- ? toolRowFailureText(bodyText, errorCode)
2049
- : target && command
2050
- ? target + ' · ' + command
2051
- : target || command || (running ? tr('tool.running', '执行中…') : tr('tool.noOutput', '(无输出)'))
2052
- const state = failed ? 'error' : running ? 'running' : 'ok'
2053
- const leading = running
2054
- ? spinner(14)
2055
- : h('svg', {
2056
- width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
2057
- 'aria-hidden': 'true', style: { display: 'block' },
2058
- },
2059
- state === 'error'
2060
- ? [
2061
- h('circle', { key: 'ring', cx: 8, cy: 8, r: 6, stroke: 'currentColor', strokeWidth: 1.2 }),
2062
- h('line', { key: 'a', x1: 5.8, y1: 5.8, x2: 10.2, y2: 10.2, stroke: 'currentColor', strokeWidth: 1.2 }),
2063
- h('line', { key: 'b', x1: 10.2, y1: 5.8, x2: 5.8, y2: 10.2, stroke: 'currentColor', strokeWidth: 1.2 }),
2064
- ]
2065
- : h('polyline', {
2066
- points: '3.5 8.5 6.5 11.5 12.5 5',
2067
- stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round',
2068
- }))
2069
- const ioSection = (labelText, text) => h('div', { className: 'dsh-ssh-workbench-io-section' },
2070
- h('span', { className: 'dsh-ssh-workbench-io-label' }, labelText),
2071
- h('span', { className: 'dsh-ssh-workbench-io-text' }, text))
2072
- const ioDivider = (key) => h('div', { key, className: 'dsh-ssh-workbench-io-divider' })
2073
- const sections = []
2074
- if (argsText) sections.push(ioSection(tr('tool.input', '参数'), argsText))
2075
- if (bodyText) sections.push(ioSection(tr('tool.output', '输出'), bodyText))
2076
- const bodyBlocks = sections.reduce((accumulator, section, index) => {
2077
- if (index > 0) accumulator.push(ioDivider('divider-' + index))
2078
- accumulator.push(section)
2079
- return accumulator
2080
- }, [])
2081
- return h('div', {
2082
- 'data-sdh-tool-card': toolName,
2083
- style: { display: 'flex', flexDirection: 'column', minWidth: 0 },
2084
- },
2085
- h('button', {
2086
- type: 'button',
2087
- className: 'dsh-ssh-workbench-tool-row',
2088
- 'data-sdh-tool-details-toggle': '',
2089
- 'aria-expanded': expanded ? 'true' : 'false',
2090
- disabled: canExpand !== true,
2091
- onClick: () => setExpanded((value) => !value),
2092
- },
2093
- h('span', { className: 'dsh-ssh-workbench-tool-leading', 'data-state': state }, leading),
2094
- h('span', { className: 'dsh-ssh-workbench-tool-title' }, label),
2095
- h('span', { className: 'dsh-ssh-workbench-tool-sep', 'aria-hidden': 'true' }),
2096
- h('span', {
2097
- className: 'dsh-ssh-workbench-tool-summary',
2098
- 'data-error': failed ? '' : undefined,
2099
- 'data-stopped': !failed && !running && /stop|cancel/i.test(errorCode) ? '' : undefined,
2100
- }, summary),
2101
- canExpand
2102
- ? h('span', { className: 'dsh-ssh-workbench-tool-action' }, expanded ? tr('tool.collapse', '收起详情') : tr('tool.expand', '查看详情'))
2103
- : null,
2104
- ),
2105
- expanded && canExpand
2106
- ? h('div', { className: 'dsh-ssh-workbench-tool-body' },
2107
- h('div', { className: 'dsh-ssh-workbench-io-card' },
2108
- bodyBlocks.length > 0
2109
- ? bodyBlocks
2110
- : h('div', { className: 'dsh-ssh-workbench-io-section' },
2111
- h('span', { className: 'dsh-ssh-workbench-io-label' }, tr('tool.output', '输出')),
2112
- h('span', { className: 'dsh-ssh-workbench-io-text' }, tr('tool.noOutput', '(无输出)')))))
2113
- : null,
2114
- )
2115
- }
2116
2140
  let translate = (key) => key
2117
2141
  /** Locale lookup with a Chinese fallback for hosts/tests where the locale service is not bound. */
2118
2142
  const tr = (key, fallback) => {
@@ -4836,16 +4860,6 @@ ${d.join(`
4836
4860
  TargetDock,
4837
4861
  ),
4838
4862
  )
4839
- for (const toolName of ['ssh_workbench_target']) {
4840
- slots.inject('tool.call.toolview', () =>
4841
- slots.register(
4842
- { name: 'tool.call.toolview', key: toolName },
4843
- function SdhSshOpsToolView(props) {
4844
- return h(SshOpsToolRow, props)
4845
- },
4846
- ),
4847
- )
4848
- }
4849
4863
  }
4850
4864
 
4851
4865
  // Native right sidebar: a page type opened by kind, plus a guide entry.
@@ -63,6 +63,11 @@ export function createActivityStore({ file = activityPath(), maxEntries = MAX_AC
63
63
  ...(entry?.server !== undefined ? { server: String(entry.server) } : {}),
64
64
  ...(entry?.summary !== undefined ? { summary: String(entry.summary) } : {}),
65
65
  ...(entry?.outcome !== undefined ? { outcome: String(entry.outcome) } : {}),
66
+ // Session/duration let the composer dock summarize this session's remote
67
+ // executions without reading DSH's private session metrics. Absent on
68
+ // older entries; readers must tolerate that.
69
+ ...(typeof entry?.sessionId === 'string' && entry.sessionId.length > 0 ? { sessionId: entry.sessionId } : {}),
70
+ ...(Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? { durationMs: Math.round(entry.durationMs) } : {}),
66
71
  }
67
72
  const next = [normalized, ...load()].slice(0, maxEntries)
68
73
  save(next)
@@ -177,13 +177,16 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
177
177
  return `${server.host}|${server.port}|${server.username}|${auth}`
178
178
  }
179
179
 
180
- function connectOptions(server, state) {
180
+ function connectOptions(server, state, connectTimeoutMs) {
181
181
  const hostKeyMode = settings.hostKeyMode === 'accept-new' ? 'accept-new' : 'strict'
182
+ const readyTimeout = Number.isInteger(connectTimeoutMs) && connectTimeoutMs > 0
183
+ ? Math.min(connectTimeoutMs, settings.connectTimeoutMs)
184
+ : settings.connectTimeoutMs
182
185
  return {
183
186
  host: server.host,
184
187
  port: server.port,
185
188
  username: server.username,
186
- readyTimeout: settings.connectTimeoutMs,
189
+ readyTimeout,
187
190
  keepaliveInterval: settings.keepaliveInterval,
188
191
  keepaliveCountMax: settings.keepaliveCountMax,
189
192
  hostHash: 'sha256',
@@ -197,7 +200,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
197
200
  }
198
201
 
199
202
  /** Open a fresh client and resolve on `ready`. */
200
- async function connectClient(server) {
203
+ async function connectClient(server, connectTimeoutMs) {
201
204
  const { Client } = await loadSsh2()
202
205
  const state = {}
203
206
  const client = new Client()
@@ -226,7 +229,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
226
229
  log('ssh client error', { server: server.name, error: error instanceof Error ? error.message : String(error) })
227
230
  })
228
231
  try {
229
- client.connect(connectOptions(server, state))
232
+ client.connect(connectOptions(server, state, connectTimeoutMs))
230
233
  } catch (error) {
231
234
  fail(error)
232
235
  }
@@ -281,7 +284,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
281
284
  }
282
285
 
283
286
 
284
- async function acquire(server) {
287
+ async function acquire(server, options = {}) {
285
288
  const id = server.id ?? server.name
286
289
  const key = serverKey(server)
287
290
  let entry = entries.get(id)
@@ -328,7 +331,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
328
331
  }
329
332
  created.pending = (async () => {
330
333
  try {
331
- const { client, fingerprint, connectMs } = await connectClient(server)
334
+ const { client, fingerprint, connectMs } = await connectClient(server, options.connectTimeoutMs)
332
335
  created.client = client
333
336
  created.fingerprint = fingerprint
334
337
  created.connectMs = connectMs
@@ -589,7 +592,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
589
592
  for (;;) {
590
593
  let acquired
591
594
  try {
592
- acquired = await acquire(server)
595
+ acquired = await acquire(server, { connectTimeoutMs: options.connectTimeoutMs })
593
596
  } catch (error) {
594
597
  const normalized = error instanceof SshError ? error : normalizeError(error)
595
598
  const result = errorResult(normalized.code, normalized.message, { durationMs: now() - startedAt })
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  /** The dialect values this plugin understands. */
12
- export const DIALECTS = Object.freeze({ POSIX: 'posix', WINDOWS: 'windows' })
12
+ const DIALECTS = Object.freeze({ POSIX: 'posix', WINDOWS: 'windows' })
13
13
 
14
14
  /** Normalize one raw dialect/platform value. */
15
15
  export function normalizeDialect(value) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Per-session execution summary for the composer target dock.
3
+ *
4
+ * DSH owns conversation/model metrics (turns, TTFT, TPS); the plugin cannot
5
+ * read them. This module only summarizes the activity entries the plugin
6
+ * writes for remote executions, so the dock can show accurate SSH-side counts
7
+ * and latency without inventing values it does not own.
8
+ */
9
+
10
+ function numericDuration(entry) {
11
+ return Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? entry.durationMs : 0
12
+ }
13
+
14
+ function emptyBucket(name) {
15
+ return { name, count: 0, success: 0, failed: 0, durationMs: 0, averageMs: 0, lastAt: null }
16
+ }
17
+
18
+ function addToBucket(bucket, entry) {
19
+ bucket.count += 1
20
+ if (entry.outcome === 'success') bucket.success += 1
21
+ else bucket.failed += 1
22
+ bucket.durationMs += numericDuration(entry)
23
+ if (typeof entry.at === 'string' && (bucket.lastAt === null || entry.at > bucket.lastAt)) bucket.lastAt = entry.at
24
+ }
25
+
26
+ /**
27
+ * Summarize the plugin's remote execution activity for one Session.
28
+ *
29
+ * @param {object[]} entries newest-first activity entries
30
+ * @param {{ sessionId?: string, candidates?: string[] }} options
31
+ * @returns {{
32
+ * sessionId: string|null,
33
+ * count: number,
34
+ * success: number,
35
+ * failed: number,
36
+ * durationMs: number,
37
+ * averageMs: number,
38
+ * lastAt: string|null,
39
+ * servers: Array<{name:string,count:number,success:number,failed:number,durationMs:number,averageMs:number,lastAt:string|null}>
40
+ * }}
41
+ */
42
+ export function summarizeExecution(entries, { sessionId, candidates = [] } = {}) {
43
+ const wanted = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null
44
+ const byServer = new Map()
45
+ const summary = {
46
+ sessionId: wanted,
47
+ count: 0,
48
+ success: 0,
49
+ failed: 0,
50
+ durationMs: 0,
51
+ averageMs: 0,
52
+ lastAt: null,
53
+ servers: [],
54
+ }
55
+
56
+ if (wanted !== null) {
57
+ for (const entry of Array.isArray(entries) ? entries : []) {
58
+ if (!entry || entry.kind !== 'exec' || entry.sessionId !== wanted) continue
59
+ summary.count += 1
60
+ if (entry.outcome === 'success') summary.success += 1
61
+ else summary.failed += 1
62
+ summary.durationMs += numericDuration(entry)
63
+ if (typeof entry.at === 'string' && (summary.lastAt === null || entry.at > summary.lastAt)) summary.lastAt = entry.at
64
+ const name = typeof entry.server === 'string' && entry.server.length > 0 ? entry.server : null
65
+ if (name === null) continue
66
+ if (!byServer.has(name)) byServer.set(name, emptyBucket(name))
67
+ addToBucket(byServer.get(name), entry)
68
+ }
69
+ }
70
+
71
+ summary.averageMs = summary.count > 0 ? Math.round(summary.durationMs / summary.count) : 0
72
+
73
+ // Keep the mentioned resources in mention order, then append any target that
74
+ // ran from an explicit remote_exec task without being a session candidate.
75
+ const names = []
76
+ for (const name of Array.isArray(candidates) ? candidates : []) {
77
+ const value = String(name)
78
+ if (value && !names.includes(value)) names.push(value)
79
+ }
80
+ for (const name of [...byServer.keys()].sort()) {
81
+ if (!names.includes(name)) names.push(name)
82
+ }
83
+ summary.servers = names.map((name) => {
84
+ const bucket = byServer.get(name) ?? emptyBucket(name)
85
+ return { ...bucket, averageMs: bucket.count > 0 ? Math.round(bucket.durationMs / bucket.count) : 0 }
86
+ })
87
+ return summary
88
+ }
@@ -58,7 +58,7 @@ const RULES = [
58
58
  ]
59
59
 
60
60
  /** Split one PowerShell command into independently-executed statements. */
61
- export function powerShellStatements(command) {
61
+ function powerShellStatements(command) {
62
62
  return String(command ?? '')
63
63
  .split(/[;\r\n]|\|\||&&|\|/)
64
64
  .map((part) => part.trim())