dsh-workspace-plus 0.6.0 → 0.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/cordis.patch.yml CHANGED
@@ -25,8 +25,18 @@
25
25
  # poll ride in the shared browser half, which serves them
26
26
  # regardless and degrades silently when this row is off.
27
27
  #
28
+ # sessionlog 会话日志上传 (exports "./sessionlog" ->
29
+ # lib/sessionlog/index.js). Node half only, and OFF by
30
+ # default. DSH's own "在使用官方模型 API 时上传 Session Log"
31
+ # only ever fires while the official DeepSeek adapter performs
32
+ # the request; this row reproduces it and adds a
33
+ # provider-independent path, so a user on a third-party model
34
+ # API can still hand the session log to DeepSeek. Its settings
35
+ # page rides in the shared browser half on the same terms as
36
+ # the notice page.
37
+ #
28
38
  # Turning one row off removes exactly that half from the host while the bundle
29
- # and the other sub-feature keep working. The 通知 settings page also carries a
39
+ # and the other sub-features keep working. The 通知 settings page also carries a
30
40
  # runtime "启用桌面通知" switch, so notifications can be silenced without
31
41
  # touching the row at all.
32
42
  - insert:
@@ -51,3 +61,25 @@
51
61
  # done: true # 任务完成
52
62
  # aborted: true # 任务中断
53
63
  # error: true # 任务出错
64
+ - id: sessionlog
65
+ name: 'dsh-workspace-plus/sessionlog'
66
+ # config: # deployment defaults for the session-log
67
+ # enabled: false # sub-feature. Ships OFF on purpose: this
68
+ # # hands conversation content to a remote
69
+ # # collector, so it must be an explicit act.
70
+ # # The 会话日志 settings page can still turn
71
+ # # it on per host.
72
+ # endpoint: '' # '' = $DSH_TELEMETRY_OTLP_URL, else the
73
+ # # DeepSeek collector the base profile uses
74
+ # requestField: false # also contribute the dsh_session_log request
75
+ # # field when nothing else owns it. OFF by
76
+ # # default: DSH performs no official-endpoint
77
+ # # check before attaching that field, so on a
78
+ # # host whose deepseek-official route points at
79
+ # # a relay it would send content to that relay.
80
+ # # Skipped anyway when the shipped
81
+ # # dsh-session-log-deepseek owns the field.
82
+ # includeHistory: true # when switched on mid-run, also send the
83
+ # # sessions that are already in the log
84
+ # maxRequestBytes: 4000000 # per-request ceiling for one upload
85
+ # shutdownTimeoutMillis: 3000 # drain deadline when the row unloads
package/lib/client.js CHANGED
@@ -71,6 +71,18 @@
71
71
  * click → `dsh-notice:` protocol → host route → pending
72
72
  * intent → THIS half → `ctx.uiWorkspace.openSession()`.
73
73
  *
74
+ * The 会话日志 (sessionlog) sub-feature — the plugin's own version of DSH's
75
+ * "在使用官方模型 API 时上传 Session Log" preference, shipped as the
76
+ * `dsh-workspace-plus/sessionlog` loader row and OFF by default — adds one more
77
+ * seat here, described in detail at its section below:
78
+ *
79
+ * `settings.section` the 会话日志 settings page, writing through
80
+ * `/dsh-sessionlog/api`. The host half owns the upload
81
+ * channel; this page only renders what the host reports
82
+ * and posts one switch or endpoint change at a time. When
83
+ * the sessionlog row is off the host answers 404 and the
84
+ * page explains that state instead of erroring.
85
+ *
74
86
  * Only the shell's frozen platform module table may be `require`d here (react,
75
87
  * react-dom, react/jsx-runtime, @deepseek-ai/cordis,
76
88
  * @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
@@ -388,8 +400,36 @@ window.__ModuleLoader__.load({
388
400
  } AUMID 发言:${toast.appId ?? '(未知)'}`
389
401
  })()
390
402
 
403
+ // How a click is actually delivered, as the host reports it after
404
+ // registering the protocol. The host prefers a compiled windowless
405
+ // helper and falls back to windowless PowerShell, then to plain
406
+ // PowerShell, so the status line names the mode instead of promising the
407
+ // best case. The helper restores and focuses the window itself, but only
408
+ // when `focusOnClick` is on — with the switch off the host registers the
409
+ // same helper without that step — so the sentence must not claim a
410
+ // foreground window in that state. A host that predates `launcher` has no
411
+ // value here, and then this returns '' so the sentence reads exactly as
412
+ // it did before.
413
+ const launcherNote = (() => {
414
+ const focus = settings.focusOnClick === true
415
+ if (protocol.launcher === 'helper') {
416
+ return focus ? '点击助手会在无窗口的情况下把 Harness 切到前台' : '由无窗口的点击助手处理,不改变窗口的前后顺序'
417
+ }
418
+ if (protocol.launcher === 'windowless-powershell') {
419
+ return focus
420
+ ? '使用无窗口 PowerShell 助手,由 Harness 负责切到前台;首次点击可能稍慢'
421
+ : '使用无窗口 PowerShell 助手;首次点击可能稍慢'
422
+ }
423
+ if (protocol.launcher === 'powershell') return '兼容模式:由 PowerShell 启动,可能有窗口闪烁'
424
+ return ''
425
+ })()
426
+
391
427
  const protocolLine = (() => {
392
- if (protocol.registered === true) return `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
428
+ if (protocol.registered === true) {
429
+ return launcherNote
430
+ ? `点击通知可打开对应对话(已注册 ${protocol.scheme}:,${launcherNote})。`
431
+ : `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
432
+ }
393
433
  if (settings.registerProtocol === false) return '已关闭协议注册:点击通知只会把 Harness 窗口切到前台。'
394
434
  return `尚未注册 ${protocol.scheme ?? 'dsh-notice'}:,点击通知只会把 Harness 窗口切到前台。`
395
435
  })()
@@ -434,7 +474,7 @@ window.__ModuleLoader__.load({
434
474
  }),
435
475
  React.createElement(NoticeSwitchRow, {
436
476
  title: '点击时把 Harness 窗口切到前台',
437
- description: '关闭后只切换对话,不改变窗口的前后顺序。',
477
+ description: '开启后点击通知会尽量把窗口从最小化、被遮挡或收进托盘的状态恢复并切到前台;关闭则只切换对话,不改变窗口的前后顺序。',
438
478
  checked: settings.focusOnClick === true,
439
479
  disabled,
440
480
  onChange: (next) => void send({ op: 'patch', patch: { focusOnClick: next } }),
@@ -827,6 +867,357 @@ window.__ModuleLoader__.load({
827
867
  document.head.appendChild(tag)
828
868
  }
829
869
 
870
+ // -----------------------------------------------------------------------
871
+ // The 会话日志 (sessionlog) sub-feature — client side
872
+ //
873
+ // DSH's own "在使用官方模型 API 时上传 Session Log" switch lives in
874
+ // Settings → General and only ever fires while the official DeepSeek adapter
875
+ // performs the request. The `dsh-workspace-plus/sessionlog` loader row
876
+ // (lib/sessionlog/index.js) reproduces that behaviour and adds a
877
+ // provider-independent OTLP channel, so the switch means the same thing for
878
+ // a user on a third-party model API. It ships OFF: handing conversation
879
+ // content to a remote collector has to be an explicit act.
880
+ //
881
+ // This page is deliberately a *report*, not a second source of truth: the
882
+ // host owns enablement, the endpoint, the channel state and the counters,
883
+ // and answers every mutation with a fresh snapshot. A 404 means the loader
884
+ // row is switched off on the plugin page — a state, not a failure — and is
885
+ // rendered as an explanation.
886
+ // -----------------------------------------------------------------------
887
+
888
+ /** Settings section identity of the 会话日志 page. */
889
+ const SESSIONLOG_SETTINGS_SECTION_ID = 'sessionlog'
890
+ const SESSIONLOG_SETTINGS_SECTION_LABEL = '会话日志'
891
+ const SESSIONLOG_SETTINGS_SECTION_ORDER = 27
892
+
893
+ /** Host route this page talks to; must match lib/sessionlog/index.js. */
894
+ const SESSIONLOG_API_PATH = '/dsh-sessionlog/api'
895
+
896
+ /** Stylesheet identity for the 会话日志 settings page (one tag, rewritten on reload). */
897
+ const SESSIONLOG_STYLE_ID = 'dsh-workspace-plus/sessionlog.css'
898
+
899
+ /** Prefix for every class the sessionlog page injects, so it can never collide. */
900
+ const SESSIONLOG_CSS = `
901
+ .dsh-sessionlog-section { display: flex; flex-direction: column; gap: 14px; }
902
+ .dsh-sessionlog-group { display: flex; flex-direction: column; gap: 10px; }
903
+ .dsh-sessionlog-group-title { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 600; letter-spacing: .04em; }
904
+ .dsh-sessionlog-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
905
+ .dsh-sessionlog-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
906
+ .dsh-sessionlog-title { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; }
907
+ .dsh-sessionlog-desc { color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.5; }
908
+ .dsh-sessionlog-switch { position: relative; flex: none; width: 38px; height: 22px; margin-top: 2px; padding: 0; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; background: var(--dsw-alias-bg-layer-2); transition: background .15s ease, border-color .15s ease; }
909
+ .dsh-sessionlog-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
910
+ .dsh-sessionlog-switch:disabled { cursor: default; opacity: .5; }
911
+ .dsh-sessionlog-knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--dsw-alias-bg-base); transition: transform .15s ease; }
912
+ .dsh-sessionlog-switch[aria-checked="true"] .dsh-sessionlog-knob { transform: translateX(16px); }
913
+ .dsh-sessionlog-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
914
+ .dsh-sessionlog-button { cursor: pointer; padding: 5px 12px; font-size: 13px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
915
+ .dsh-sessionlog-button:hover:not(:disabled) { border-color: var(--dsw-alias-border-l1); }
916
+ .dsh-sessionlog-button:disabled { cursor: default; opacity: .5; }
917
+ .dsh-sessionlog-input { flex: 1 1 260px; min-width: 0; padding: 5px 9px; font-size: 12.5px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
918
+ .dsh-sessionlog-status { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.6; }
919
+ .dsh-sessionlog-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
920
+ .dsh-sessionlog-error { color: var(--dsw-alias-state-error-primary); }
921
+ .dsh-sessionlog-ok { color: var(--dsw-alias-state-success-primary); }
922
+ `
923
+
924
+ /** Install the sessionlog stylesheet, exactly one tag per document (rewritten on reload). */
925
+ function ensureSessionlogStyles() {
926
+ if (typeof document === 'undefined') return
927
+ const existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(SESSIONLOG_STYLE_ID) + ']')
928
+ if (existing !== null) {
929
+ existing.textContent = SESSIONLOG_CSS
930
+ return
931
+ }
932
+ const tag = document.createElement('style')
933
+ tag.dataset.plugin = 'dsh-workspace-plus'
934
+ tag.dataset.pluginCss = SESSIONLOG_STYLE_ID
935
+ tag.textContent = SESSIONLOG_CSS
936
+ document.head.appendChild(tag)
937
+ }
938
+
939
+ /**
940
+ * Fetch the sessionlog host's snapshot.
941
+ *
942
+ * @param signal - caller cancellation.
943
+ * @returns the parsed payload.
944
+ */
945
+ async function fetchSessionlogSnapshot(signal) {
946
+ const response = await fetch(SESSIONLOG_API_PATH, { cache: 'no-store', signal })
947
+ if (!response.ok) throw new Error(`host responded ${response.status}`)
948
+ return await response.json()
949
+ }
950
+
951
+ /**
952
+ * Post one sessionlog control operation.
953
+ *
954
+ * @param body - operation payload.
955
+ * @returns the parsed payload.
956
+ */
957
+ async function postSessionlogOperation(body) {
958
+ const response = await fetch(SESSIONLOG_API_PATH, {
959
+ method: 'POST',
960
+ cache: 'no-store',
961
+ headers: { 'content-type': 'application/json' },
962
+ body: JSON.stringify(body),
963
+ })
964
+ if (!response.ok && response.status >= 500) throw new Error(`host responded ${response.status}`)
965
+ return await response.json()
966
+ }
967
+
968
+ /**
969
+ * One labelled switch row of the 会话日志 page.
970
+ *
971
+ * @param props - `{ title, description, checked, disabled, onChange }`.
972
+ * @returns the row element.
973
+ */
974
+ function SessionlogSwitchRow(props) {
975
+ return React.createElement(
976
+ 'div',
977
+ { className: 'dsh-sessionlog-row' },
978
+ React.createElement(
979
+ 'div',
980
+ { className: 'dsh-sessionlog-text' },
981
+ React.createElement('div', { className: 'dsh-sessionlog-title' }, props.title),
982
+ props.description ? React.createElement('div', { className: 'dsh-sessionlog-desc' }, props.description) : null,
983
+ ),
984
+ React.createElement(
985
+ 'button',
986
+ {
987
+ type: 'button',
988
+ role: 'switch',
989
+ 'aria-checked': props.checked === true,
990
+ 'aria-label': props.title,
991
+ disabled: props.disabled === true,
992
+ className: 'dsh-sessionlog-switch',
993
+ onClick: () => props.onChange(props.checked !== true),
994
+ },
995
+ React.createElement('span', { className: 'dsh-sessionlog-knob' }),
996
+ ),
997
+ )
998
+ }
999
+
1000
+ /** Human label for the request-field channel's current state. */
1001
+ function sessionlogRequestFieldLine(channel) {
1002
+ switch (channel?.requestField) {
1003
+ case 'active':
1004
+ return 'dsh_session_log 请求字段:由本插件提供(官方 DeepSeek 适配器请求时随请求发出)。'
1005
+ case 'taken':
1006
+ return 'dsh_session_log 请求字段:已由其它插件提供(通常是内置的 dsh-session-log-deepseek),本插件自动跳过。'
1007
+ case 'unavailable':
1008
+ return 'dsh_session_log 请求字段:当前组合里没有 DeepSeek 扩展注册表,这一路不可用。'
1009
+ default:
1010
+ return 'dsh_session_log 请求字段:未启用。'
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * The 会话日志 settings page. It never predicts what the host accepted: every
1016
+ * switch posts one key and adopts the snapshot that comes back, and a 404 —
1017
+ * the sessionlog loader row being switched off — renders as an explanation
1018
+ * rather than an error.
1019
+ *
1020
+ * `props.initial` is a test seam (the shell never passes it), matching the
1021
+ * workspace+ page: it seeds the snapshot/status and skips the mount fetch so
1022
+ * a test can render the ready and failure states without a live host.
1023
+ *
1024
+ * @param props - optional `{ initial }`.
1025
+ * @returns the settings section element.
1026
+ */
1027
+ function SessionlogSettings(props) {
1028
+ const injected = props !== null && typeof props === 'object' ? props.initial : undefined
1029
+ const [snapshot, setSnapshot] = React.useState(injected?.snapshot ?? null)
1030
+ const [status, setStatus] = React.useState(injected?.status ?? { kind: 'loading', message: '' })
1031
+ const [busy, setBusy] = React.useState(injected?.busy === true)
1032
+ const [draft, setDraft] = React.useState(String(injected?.draft ?? ''))
1033
+
1034
+ React.useEffect(() => {
1035
+ if (injected !== undefined) return undefined
1036
+ let cancelled = false
1037
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
1038
+ ;(async () => {
1039
+ try {
1040
+ const payload = await fetchSessionlogSnapshot(controller?.signal)
1041
+ if (cancelled) return
1042
+ setSnapshot(payload)
1043
+ setDraft(String(payload?.settings?.endpoint ?? ''))
1044
+ setStatus({ kind: 'ready', message: '' })
1045
+ } catch (error) {
1046
+ if (cancelled) return
1047
+ setStatus({ kind: 'error', message: String((error && error.message) || error) })
1048
+ }
1049
+ })()
1050
+ return () => {
1051
+ cancelled = true
1052
+ controller?.abort()
1053
+ }
1054
+ }, [])
1055
+
1056
+ const send = React.useCallback(async (body) => {
1057
+ setBusy(true)
1058
+ try {
1059
+ const payload = await postSessionlogOperation(body)
1060
+ // A rejected operation still carries a snapshot (the host always
1061
+ // answers with one), so the page can adopt it and show the reason.
1062
+ if (payload !== null && typeof payload === 'object' && payload.settings !== undefined) {
1063
+ setSnapshot(payload)
1064
+ setDraft(String(payload.settings.endpoint ?? ''))
1065
+ }
1066
+ setStatus({
1067
+ kind: payload !== null && payload.ok === true ? 'ready' : 'error',
1068
+ message: String(payload?.message ?? ''),
1069
+ })
1070
+ } catch (error) {
1071
+ setStatus({ kind: 'error', message: String((error && error.message) || error) })
1072
+ } finally {
1073
+ setBusy(false)
1074
+ }
1075
+ }, [])
1076
+
1077
+ if (snapshot === null) {
1078
+ const message = status.kind === 'error' ? status.message : ''
1079
+ if (status.kind === 'error' && message.includes('404')) {
1080
+ return React.createElement(
1081
+ 'div',
1082
+ { className: 'dsh-sessionlog-section' },
1083
+ React.createElement(
1084
+ 'div',
1085
+ { className: 'dsh-sessionlog-desc' },
1086
+ '会话日志子功能当前未启用:在侧栏「插件」页的 dsh-workspace-plus 卡片里打开 sessionlog 这一行即可开启。',
1087
+ ),
1088
+ )
1089
+ }
1090
+ return React.createElement(
1091
+ 'div',
1092
+ { className: 'dsh-sessionlog-section' },
1093
+ React.createElement(
1094
+ 'div',
1095
+ { className: 'dsh-sessionlog-desc' },
1096
+ status.kind === 'error' ? `读取设置失败:${message}` : '正在读取设置…',
1097
+ ),
1098
+ )
1099
+ }
1100
+
1101
+ const settings = snapshot.settings ?? {}
1102
+ const channel = snapshot.channel ?? {}
1103
+ const stats = snapshot.stats ?? {}
1104
+ const on = settings.enabled === true
1105
+ const endpointSource =
1106
+ snapshot.endpointSource === 'settings' ? '设置页指定' : snapshot.endpointSource === 'environment' ? '环境变量' : '出厂默认'
1107
+ const reporting = channel.reporting === true
1108
+ const lastError = typeof stats.lastError === 'string' && stats.lastError !== '' ? stats.lastError : ''
1109
+
1110
+ return React.createElement(
1111
+ 'div',
1112
+ { className: 'dsh-sessionlog-section' },
1113
+ React.createElement(
1114
+ 'div',
1115
+ { className: 'dsh-sessionlog-group' },
1116
+ React.createElement(SessionlogSwitchRow, {
1117
+ title: '上传 Session Log(帮助改进 DeepSeek 模型与产品)',
1118
+ description:
1119
+ '默认关闭。开启后,本机会话日志的内容会发送到 DeepSeek 的收集端点;与使用哪个模型 API 无关,因此换用第三方模型 API 时同样生效。',
1120
+ checked: on,
1121
+ disabled: busy,
1122
+ onChange: (next) => void send({ op: 'patch', patch: { enabled: next } }),
1123
+ }),
1124
+ React.createElement(
1125
+ 'div',
1126
+ { className: 'dsh-sessionlog-status' },
1127
+ React.createElement('div', null, reporting ? `上报中:${stats.events ?? 0} 条事件 / ${stats.sessions ?? 0} 个会话` : on ? '已开启,等待通道就绪…' : '未开启:本机不会向 DeepSeek 发送任何会话内容。'),
1128
+ React.createElement('div', null, channel.otlpAvailable === true ? 'OTLP 会话日志通道:可用。' : 'OTLP 会话日志通道:当前组合未挂载 otel 服务,无法上传。'),
1129
+ React.createElement('div', null, sessionlogRequestFieldLine(channel)),
1130
+ // The host's process-level opt-out outranks this page's switch, and
1131
+ // saying so is the difference between "the switch is broken" and
1132
+ // "this machine has been told not to report".
1133
+ snapshot.killSwitch
1134
+ ? React.createElement('div', { className: 'dsh-sessionlog-error' }, `进程级遥测开关:${String(snapshot.killSwitch)} —— 本页的开关不会覆盖它。`)
1135
+ : null,
1136
+ React.createElement(
1137
+ 'div',
1138
+ { className: 'dsh-sessionlog-mono' },
1139
+ `目标端点(${endpointSource}):${String(snapshot.effectiveEndpoint ?? '')}`,
1140
+ ),
1141
+ // Redaction is unconditional, so this is a receipt rather than a control.
1142
+ stats.redactions > 0
1143
+ ? React.createElement(
1144
+ 'div',
1145
+ { className: 'dsh-sessionlog-ok' },
1146
+ `已在发出前脱敏 ${String(stats.redactions)} 处凭据(token / 密钥 / Authorization)。`,
1147
+ )
1148
+ : null,
1149
+ lastError ? React.createElement('div', { className: 'dsh-sessionlog-error' }, `最近一次错误:${lastError}`) : null,
1150
+ ),
1151
+ ),
1152
+ React.createElement(
1153
+ 'div',
1154
+ { className: 'dsh-sessionlog-group' },
1155
+ React.createElement('div', { className: 'dsh-sessionlog-group-title' }, '上传范围'),
1156
+ React.createElement(SessionlogSwitchRow, {
1157
+ title: '同时提供 dsh_session_log 请求字段',
1158
+ description:
1159
+ '默认关闭。仅当官方 DeepSeek 适配器承担这次请求、且没有别的插件占用该字段时才生效;内置的 dsh-session-log-deepseek 在场时会自动跳过。注意:DSH 不检查该适配器的 baseURL 是否指向官方域名,字段会随请求发到它配置的任意端点。',
1160
+ checked: settings.requestField === true,
1161
+ disabled: busy || !on,
1162
+ onChange: (next) => void send({ op: 'patch', patch: { requestField: next } }),
1163
+ }),
1164
+ React.createElement(SessionlogSwitchRow, {
1165
+ title: '开启时补传已有会话历史',
1166
+ description: '关闭后,只上传开启之后新产生的事件;已经存在的对话不会补传。',
1167
+ checked: settings.includeHistory === true,
1168
+ disabled: busy || !on,
1169
+ onChange: (next) => void send({ op: 'patch', patch: { includeHistory: next } }),
1170
+ }),
1171
+ ),
1172
+ React.createElement(
1173
+ 'div',
1174
+ { className: 'dsh-sessionlog-group' },
1175
+ React.createElement('div', { className: 'dsh-sessionlog-group-title' }, '收集端点'),
1176
+ React.createElement(
1177
+ 'div',
1178
+ { className: 'dsh-sessionlog-actions' },
1179
+ React.createElement('input', {
1180
+ type: 'text',
1181
+ className: 'dsh-sessionlog-input',
1182
+ spellCheck: false,
1183
+ placeholder: '留空则使用环境变量 DSH_TELEMETRY_OTLP_URL,否则用出厂端点',
1184
+ value: draft,
1185
+ disabled: busy,
1186
+ onChange: (event) => setDraft(String(event?.target?.value ?? '')),
1187
+ }),
1188
+ React.createElement(
1189
+ 'button',
1190
+ {
1191
+ type: 'button',
1192
+ className: 'dsh-sessionlog-button',
1193
+ disabled: busy || draft === String(settings.endpoint ?? ''),
1194
+ onClick: () => void send({ op: 'patch', patch: { endpoint: draft } }),
1195
+ },
1196
+ '保存',
1197
+ ),
1198
+ React.createElement(
1199
+ 'button',
1200
+ {
1201
+ type: 'button',
1202
+ className: 'dsh-sessionlog-button',
1203
+ disabled: busy,
1204
+ onClick: () => void send({ op: 'reset' }),
1205
+ },
1206
+ '全部恢复默认',
1207
+ ),
1208
+ ),
1209
+ React.createElement(
1210
+ 'div',
1211
+ { className: 'dsh-sessionlog-desc' },
1212
+ '设置文件:' + String(snapshot.storePath ?? ''),
1213
+ ),
1214
+ ),
1215
+ status.kind === 'error' && status.message !== ''
1216
+ ? React.createElement('div', { className: 'dsh-sessionlog-error' }, status.message)
1217
+ : null,
1218
+ )
1219
+ }
1220
+
830
1221
  ensureStyles()
831
1222
 
832
1223
  // The notice page's stylesheet follows the same one-tag rule; calling it in
@@ -834,6 +1225,11 @@ window.__ModuleLoader__.load({
834
1225
  // `<style>` element of its own and reloads stay duplicate-free.
835
1226
  ensureNoticeStyles()
836
1227
 
1228
+ // The 会话日志 page's stylesheet follows the same rule and MUST be appended
1229
+ // last: the shell's own stylesheet checks read `head.children[0]`/`[1]` as
1230
+ // "workspace+ then notice", so a new sheet only ever goes on the end.
1231
+ ensureSessionlogStyles()
1232
+
837
1233
  // -----------------------------------------------------------------------
838
1234
  // The workspace-directory feature
839
1235
  // -----------------------------------------------------------------------
@@ -1871,11 +2267,16 @@ window.__ModuleLoader__.load({
1871
2267
  const bodyBlocks = (() => {
1872
2268
  if (state.status === 'error') {
1873
2269
  const unauthorized = state.message.includes('401')
2270
+ const unavailable = state.message.includes('503')
1874
2271
  const unrouted = state.message.includes('404')
1875
2272
  /**
1876
- * The three failures need different advice:
2273
+ * The four failures need different advice:
1877
2274
  * - 401: this document never completed the token exchange, so the
1878
2275
  * connection layer has no authority to admit the request.
2276
+ * - 503: the Host has no usable authentication service, so the route
2277
+ * refused the request instead of accepting an unauthenticated
2278
+ * change. Restarting is the ordinary fix; the route stays shut until
2279
+ * the connection service is mounted.
1879
2280
  * - 404: the request reached the Host and nothing claimed the path.
1880
2281
  * The browser half hot-reloads from the served bundle, the host half
1881
2282
  * does not, so a stale Host process is the ordinary cause.
@@ -1883,14 +2284,16 @@ window.__ModuleLoader__.load({
1883
2284
  */
1884
2285
  const diagnosis = unauthorized
1885
2286
  ? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
1886
- : unrouted
1887
- ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
1888
- API_PATH +
1889
- ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
1890
- '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
1891
- : '这个面板通过 ' +
1892
- API_PATH +
1893
- ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
2287
+ : unavailable
2288
+ ? '宿主进程没有可用的认证服务,接口按安全默认拒绝了这次请求(503)。重启 dsh 通常即可恢复;如果一直出现,说明这个宿主没有挂载 connection 认证服务,这个接口在它上面不会放行任何读写。'
2289
+ : unrouted
2290
+ ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
2291
+ API_PATH +
2292
+ ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
2293
+ '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
2294
+ : '这个面板通过 ' +
2295
+ API_PATH +
2296
+ ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
1894
2297
  return [
1895
2298
  noticeBlock(
1896
2299
  'error',
@@ -2479,6 +2882,29 @@ window.__ModuleLoader__.load({
2479
2882
  ),
2480
2883
  )
2481
2884
 
2885
+ // The 会话日志 sub-feature's settings page. Same guard as the notice page:
2886
+ // the browser half always loads with the bundle, so the page has to survive
2887
+ // its host row being switched off (the host then answers 404 and the page
2888
+ // renders that as a state), and a registration failure must not take the
2889
+ // rest of this bundle down with it.
2890
+ registerNotice(ctx, '会话日志设置页', () =>
2891
+ ctx.effect(
2892
+ () =>
2893
+ ctx.slots.inject('settings.section', () =>
2894
+ ctx.slots.register(
2895
+ {
2896
+ name: 'settings.section',
2897
+ id: SESSIONLOG_SETTINGS_SECTION_ID,
2898
+ order: SESSIONLOG_SETTINGS_SECTION_ORDER,
2899
+ label: SESSIONLOG_SETTINGS_SECTION_LABEL,
2900
+ },
2901
+ SessionlogSettings,
2902
+ ),
2903
+ ),
2904
+ 'dsh-workspace-plus/sessionlog: settings section',
2905
+ ),
2906
+ )
2907
+
2482
2908
  // The dialog lives in the frame-wide overlay: the same seat the shell's own
2483
2909
  // workspace Rename/Delete dialogs occupy.
2484
2910
  ctx.effect(
@@ -2622,6 +3048,17 @@ window.__ModuleLoader__.load({
2622
3048
  exports.NOTICE_PENDING_PATH = NOTICE_PENDING_PATH
2623
3049
  exports.NOTICE_POLL_INTERVAL_MS = NOTICE_POLL_INTERVAL_MS
2624
3050
  exports.NOTICE_POLL_RETRY_MS = NOTICE_POLL_RETRY_MS
3051
+ exports.SESSIONLOG_SETTINGS_SECTION_ID = SESSIONLOG_SETTINGS_SECTION_ID
3052
+ exports.SESSIONLOG_SETTINGS_SECTION_LABEL = SESSIONLOG_SETTINGS_SECTION_LABEL
3053
+ exports.SESSIONLOG_SETTINGS_SECTION_ORDER = SESSIONLOG_SETTINGS_SECTION_ORDER
3054
+ exports.SESSIONLOG_API_PATH = SESSIONLOG_API_PATH
3055
+ exports.SESSIONLOG_STYLE_ID = SESSIONLOG_STYLE_ID
3056
+ exports.ensureSessionlogStyles = ensureSessionlogStyles
3057
+ exports.fetchSessionlogSnapshot = fetchSessionlogSnapshot
3058
+ exports.postSessionlogOperation = postSessionlogOperation
3059
+ exports.SessionlogSwitchRow = SessionlogSwitchRow
3060
+ exports.SessionlogSettings = SessionlogSettings
3061
+ exports.sessionlogRequestFieldLine = sessionlogRequestFieldLine
2625
3062
  exports.fetchNoticeSnapshot = fetchNoticeSnapshot
2626
3063
  exports.postNoticeOperation = postNoticeOperation
2627
3064
  exports.NoticeSwitchRow = NoticeSwitchRow
package/lib/index.js CHANGED
@@ -12,7 +12,9 @@
12
12
  * agent sees the mapping without spending a tool call
13
13
  * 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
14
14
  * when the user asks in natural language
15
- * 4. a small JSON HTTP route the browser half uses to manage the same store
15
+ * 4. a small JSON HTTP route the browser half uses to manage the same store —
16
+ * every request must pass the Host's `connection.admit` check, and the
17
+ * route refuses (503) when that service is unavailable
16
18
  *
17
19
  * The whole point is routing by label instead of by guesswork: the injected
18
20
  * table gives absolute paths, so a similar-looking directory name elsewhere on
@@ -362,6 +364,55 @@ export function validateLabel(label) {
362
364
  return { ok: true, label: text }
363
365
  }
364
366
 
367
+ /**
368
+ * Run the Host's browser trust check for one plugin request.
369
+ *
370
+ * `connection.admit(req)` is the only authority a plugin route has: the Host
371
+ * fence plus the GUI session cookie. This route both publishes the mapping and
372
+ * lets the caller rewrite it, and whatever it stores is rendered into every
373
+ * request's model context — so it fails closed when that authority is absent
374
+ * instead of falling through to "admitted":
375
+ *
376
+ * - no `connection` service mounted, or a service without `admit` — the host
377
+ * composition has no auth layer (or its API drifted): refuse with 503;
378
+ * - `admit` throwing, rejecting, or failing to answer: the decision is
379
+ * unavailable, so refuse with 503;
380
+ * - a rejection from `admit`: pass its 401 through, everything else is 403.
381
+ *
382
+ * The result is awaited, so a synchronous or an asynchronous `admit` is honored
383
+ * the same way — but only an answer that actually carries no rejection counts
384
+ * as an admission.
385
+ *
386
+ * The notice sub-feature deliberately grants a loopback exemption. This route
387
+ * does not: an unauthenticated writer could otherwise plant arbitrary text in
388
+ * the agent's prompt through a directory note.
389
+ *
390
+ * @param connection - the resolved `connection` service, or undefined.
391
+ * @param req - incoming request.
392
+ * @returns a promise of `{ ok: true }` or `{ ok: false, status, message }`.
393
+ */
394
+ export async function checkAdmission(connection, req) {
395
+ const unavailable = (message) => ({ ok: false, status: 503, message })
396
+ if (connection === undefined || connection === null) {
397
+ return unavailable('host authentication service is not mounted; refusing to answer')
398
+ }
399
+ if (typeof connection.admit !== 'function') {
400
+ return unavailable('host authentication service has no admit(); refusing to answer')
401
+ }
402
+ let admission
403
+ try {
404
+ admission = await connection.admit(req)
405
+ } catch (error) {
406
+ return unavailable(`host authentication service failed: ${String(error?.message ?? error)}`)
407
+ }
408
+ const rejection = admission !== null && typeof admission === 'object' ? admission.rejection : undefined
409
+ if (rejection !== undefined) {
410
+ const status = rejection === 401 ? 401 : 403
411
+ return { ok: false, status, message: status === 401 ? 'unauthorized' : 'forbidden' }
412
+ }
413
+ return { ok: true }
414
+ }
415
+
365
416
  /**
366
417
  * Apply one store operation. Every branch validates first and returns a
367
418
  * discriminated result, so both the Tool and the HTTP route share one behavior
@@ -709,11 +760,17 @@ export function apply(ctx) {
709
760
  kind: 'exact',
710
761
  path: API_PATH,
711
762
  handler: async (req, res) => {
712
- const connection = ctx.get('connection')
713
- const admission = connection?.admit?.(req)
714
- if (admission !== undefined && 'rejection' in admission) {
715
- res.writeHead(admission.rejection)
716
- res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
763
+ // Fail closed. A request that cannot be admitted by the Host's
764
+ // connection service gets no answer at all, read or write: the
765
+ // mapping this route returns — and the notes it stores — become
766
+ // model context, so an unauthenticated caller must never reach it.
767
+ const admission = await checkAdmission(ctx.get('connection'), req)
768
+ if (!admission.ok) {
769
+ res.writeHead(admission.status, {
770
+ 'content-type': 'application/json; charset=utf-8',
771
+ 'cache-control': 'no-store',
772
+ })
773
+ res.end(JSON.stringify({ ok: false, message: admission.message }))
717
774
  return
718
775
  }
719
776
  const send = (status, payload) => {