dsh-asuta-user-agent-settings 0.0.0-stage → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asuta and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # Agent 全局设置
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 设置页新增 **Agent 全局设置**:
4
+
5
+ - 编辑 `$DSH_HOME/AGENTS.md`(默认用户 `.dsh/AGENTS.md`)。这就是原生 agent-instructions 读取的全局文件;未保存前不创建。项目/Agent 自身指令保留;不是覆盖系统约束的最高优先级。现有主/子 Agent 在原生下一请求指令协调时读取变化。
6
+ - 独立选择自动/手动/选区压缩的提供商和模型,或跟随当前会话。仅包装原生 BasicCompactionEngine 的 summarize 定制点,按每次调用建立不可变 config 视图;不改 Agent 模型、不监听 llm/stream、不改变主对话路由。自定义/未知引擎不假称支持。
7
+ - 刷新前端按钮调用当前窗口 location.reload,不重启后端;明确提醒可能丢失未保存设置或草稿。
8
+
9
+ ## 保存与数据
10
+
11
+ 指令及模型选择分别保存,均以读取时 revision 做冲突检查;串行保存+同目录临时文件原子替换,临时文件失败清理,不留备份副本。编辑器仍拒绝符号链接和超出 128 KiB 的文件;Agent 运行读取单独使用原生兼容的 1 MiB 上限并允许文件符号链接,回退读取失败会清空缓存、记录诊断但不阻断下一步请求。压缩选择保存为 `$DSH_HOME/plugin-data/user-agent-settings/settings.json`。
12
+
13
+ UI 和模型工具 `user_agent_settings` 调用同一 Host operation;模型保存入口先解析实际调用者(含子 Agent)的 sandboxPolicy,只有 danger-full-access 或原生本次审批通过才允许写入全局文件。拒绝、取消、无审批服务/通道或缺失调用上下文均不落盘,也不改变会话权限;read/models 不需要写入审批。工具延迟加载,只输出内容/元数据,不发模型请求。刷新是纯用户界面操作,不提供工具。HTTP route 仅同源并要求自定义请求头,拒绝跨源及 GET 修改;请求体上限预留最坏六倍 JSON 转义及元数据空间,解码后仍由核心执行 128 KiB UTF-8 限制。
14
+
15
+ ## 本次审查修复(1.0.3)
16
+
17
+ - 隔离组合回归:113 项中 112 项通过、0 失败,1 项文件符号链接测试因 Windows `EPERM` 跳过;另 22 项持久化断言通过。
18
+ - 新增覆盖:模型保存的调用者权限、审批失败/取消、运行读取大小与故障恢复、最大合法 JSON 转义请求;侧栏测试同时覆盖同 slot 失效和卸载恢复。
19
+ - 语法、发布文件清单和 `git diff --check` 通过。未发起真实审批、改写实际全局设置、热重载插件或进行真实界面验收;未提交或推送本次修复。
20
+
21
+ ## 兼容性与验证
22
+
23
+ 全局文件由已挂载的原生 agent-instructions 读取;Minimal 等未加载原生模块的预设通过 required prompt context 补充。当前 standard、ptc、cordis 使用原生路径,minimal 使用回退;回退不覆盖项目/角色指令且原生路径不重复注入。同 ID 预设的旧 revision 若改变原生加载器开关,以及条件启用的加载器,状态会有兼容性边界;切换这类自定义组合后需重新核验。压缩 adapter 基于当前原生 BasicCompactionEngine 的受检查实现形状,对当前及后续主/子 Agent 作用,卸载恢复原方法,不覆盖之后安装的其它包装。新模型选择在下一次压缩生效,进行中的压缩保持原选择。
24
+
25
+ 独立 tests 在 `.user-agent-settings`:core 使用工作区隔离 fixture,覆盖 UTF-8 限制、版本冲突、并发保存、重载、无效模型失败不落盘;不改实际全局指令和用户模型配置。压缩测试使用无 I/O 假引擎,不消耗真实模型额度。真实已安装状态需另外检查。
26
+
27
+ 历史重新编辑/永久截断不在本插件中实现。当前内核追加式日志缺少跨缓存、搜索与存储代际的安全不可逆重写事务,不能把 UI 隐藏当作删除。
28
+
29
+ ### 最终验收(2026-10-26)
30
+
31
+ - 已安装:dsh-asuta-user-agent-settings 1.0.2;Client settings.section 的 user-agent-settings 入口 active。
32
+ - 实机读取:standard、ptc、cordis 原生指令加载;minimal required-context 回退 active;压缩 adapter 已接入原生主/子 Agent 作用域,无状态错误。
33
+ - 组合回归:35 项测试及 22 项持久化断言全部通过;语法和 git diff --check 通过。隔离测试不修改真实用户指令、模型选择或会话历史。
34
+ - 验收边界:设置页面布局与点击保存尚未在已登录主窗口自动验收;刷新按钮未由 Agent 自动触发。默认仍为跟随会话,未替用户指定压缩模型。
35
+ - 未进行 Git 提交或推送。
package/client.js ADDED
@@ -0,0 +1,68 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-asuta-user-agent-settings',
3
+ factory(require) {
4
+ const React = require('react'), h = React.createElement
5
+ const NS = 'userAgentSettings'
6
+ const zh = { title:'Agent 全局设置',intro:'适用于所有工作区的主 Agent 和子 Agent;项目和角色自身的指令仍然保留。',instructions:'全局 Agent 文件',fileHelp:'使用 DSH 原生全局 AGENTS.md,不重复注入提示词。保存后从下一次请求读取;不会中断正在运行的回复。',notCreated:'文件尚未创建,首次保存时创建。',save:'保存',saving:'保存中…',saved:'已保存',reload:'重新读取',discard:'有未保存的修改,确定丢弃并重新读取吗?',loading:'正在加载设置…',error:'操作失败:',conflict:'内容已在其它位置改变,请先重新读取再保存。',compression:'上下文压缩模型',compressionHelp:'仅影响自动、手动及选区压缩,不改变正常对话模型。默认跟随每条会话的模型。模型不可用时明确报错,不静默切换。',follow:'跟随当前会话模型',choose:'指定模型',provider:'提供商',model:'模型',saveCompression:'保存压缩设置',modelPlaceholder:'选择模型或输入精确模型 ID',capability:'适配状态',refresh:'刷新前端',refreshHelp:'重新加载当前界面,相当于 Ctrl+R,不重启 DSH 后端。其它页面中尚未发送或保存的内容可能丢失。',refreshButton:'刷新当前界面',refreshConfirm:'刷新会重新加载当前界面,未保存的编辑或草稿可能丢失。是否继续?',refreshDirty:'本页还有未保存的设置。刷新后这些修改会丢失,是否继续?',limit:'最多 128 KiB;此文件不是最高优先级指令,不覆盖系统或项目约束。',statusReady:'已接入原生压缩流程',statusLimited:'部分压缩引擎不兼容,请查看状态',noProviders:'没有可用的模型提供商,请先在模型设置中配置。',retry:'重试' }
7
+ const en = { title:'Global Agent Settings',intro:'Applies to main agents and subagents in every workspace, alongside project and role instructions.',instructions:'Global Agent file',fileHelp:'Edits the native global AGENTS.md without duplicate prompt injection. Changes apply from the next request; running replies are not interrupted.',notCreated:'The file does not exist yet. Saving creates it.',save:'Save',saving:'Saving…',saved:'Saved',reload:'Reload from disk',discard:'Discard unsaved edits and reload from disk?',loading:'Loading settings…',error:'Operation failed: ',conflict:'The content changed elsewhere. Reload before saving.',compression:'Context compression model',compressionHelp:'Applies only to automatic, manual and region compression, not normal replies. By default, follows each conversation model. Unavailable models fail explicitly.',follow:'Follow conversation model',choose:'Choose a model',provider:'Provider',model:'Model',saveCompression:'Save compression setting',modelPlaceholder:'Choose or enter the exact model ID',capability:'Adapter status',refresh:'Refresh frontend',refreshHelp:'Reloads the current interface like Ctrl+R without restarting the DSH backend. Unsent or unsaved content elsewhere may be lost.',refreshButton:'Reload this interface',refreshConfirm:'Reload this interface? Unsaved edits or drafts may be lost.',refreshDirty:'This page has unsaved settings. Reloading will discard them. Continue?',limit:'Up to 128 KiB. Global instructions do not override system or project constraints.',statusReady:'Connected to native compression',statusLimited:'Some compression engines are unsupported; inspect status',noProviders:'No model providers are available. Configure one in model settings first.',retry:'Retry' }
8
+ async function request(body, signal) {
9
+ const response = await fetch('/__user-agent-settings/operation',{method:'POST',headers:{'content-type':'application/json','x-user-agent-settings':'1'},body:JSON.stringify(body),signal})
10
+ const result = await response.json()
11
+ if (!response.ok || !result.ok) throw Object.assign(new Error(result.error || 'Request failed'), { code: result.code })
12
+ return result
13
+ }
14
+ function Page({ t }) {
15
+ const tr = React.useCallback(key => { const fallback = String(navigator.language).toLowerCase().startsWith('zh') ? zh : en; const value = t?.(key); return value && value !== key ? value : fallback[key] ?? key },[t])
16
+ const [state,setState] = React.useState(null), [providers,setProviders] = React.useState([]),[text,setText] = React.useState(''),[mode,setMode] = React.useState('follow'),[provider,setProvider] = React.useState(''),[model,setModel] = React.useState(''),[busy,setBusy] = React.useState(''),[notice,setNotice] = React.useState(null)
17
+ const original = state?.compaction
18
+ const instructionDirty = !!state && text !== state.instructions.text
19
+ const selectionDirty = !!state && JSON.stringify(mode==='follow'?null:{provider,model}) !== JSON.stringify(original)
20
+ const dirty = instructionDirty || selectionDirty
21
+ const showError = error => setNotice({error:true,text:error.code==='CONFLICT'?tr('conflict'):tr('error')+error.message})
22
+ const load = React.useCallback(async signal => {
23
+ setBusy('load');setNotice(null)
24
+ try {
25
+ const result = await request({action:'read'},signal); if(signal?.aborted)return
26
+ setState(result);setText(result.instructions.text);setMode(result.compaction?'choose':'follow');setProvider(result.compaction?.provider??'');setModel(result.compaction?.model??'')
27
+ const catalog=await request({action:'models'},signal);if(!signal?.aborted)setProviders(catalog.providers)
28
+ } catch(error){if(error.name!=='AbortError')showError(error)}
29
+ finally { if(!signal?.aborted)setBusy('') }
30
+ },[tr])
31
+ React.useEffect(()=>{const controller=new AbortController();void load(controller.signal);return()=>controller.abort()},[load])
32
+ React.useEffect(()=>{if(!dirty)return;const warn=event=>{event.preventDefault();event.returnValue=''};window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn)},[dirty])
33
+ async function saveInstructions(){setBusy('instructions');setNotice(null);try{const result=await request({action:'save_instructions',text,revision:state.instructions.revision});setState(previous=>({...previous,instructions:result.instructions}));setNotice({text:tr('saved')})}catch(error){showError(error)}finally{setBusy('')}}
34
+ async function saveCompression(){setBusy('compression');setNotice(null);try{const result=await request({action:'save_compaction',compaction:mode==='follow'?null:{provider,model},revision:state.revision});setState(previous=>({...previous,compaction:result.compaction,revision:result.revision,status:result.status}));setNotice({text:tr('saved')})}catch(error){showError(error)}finally{setBusy('')}}
35
+ const models=providers.find(item=>item.id===provider)?.models??[]
36
+ const button=(label,onClick,disabled=false,primary=false)=>h('button',{type:'button',className:'uas-button'+(primary?' uas-primary':''),onClick,disabled:!!busy||disabled},label)
37
+ const card=(title,...children)=>h('section',{className:'uas-card'},h('h3',null,title),...children)
38
+ return h('div',{className:'uas-page','data-plugin':'dsh-asuta-user-agent-settings'},h('style',null,CSS),h('h2',null,tr('title')),h('p',{className:'uas-muted'},tr('intro')),
39
+ notice?h('div',{role:notice.error?'alert':'status',className:'uas-notice'+(notice.error?' uas-error':'')},notice.text):null,
40
+ !state?h('div',null,tr('loading'),!busy?button(tr('retry'),()=>load()):null):h(React.Fragment,null,
41
+ card(tr('instructions'),h('p',{className:'uas-muted'},tr('fileHelp')),h('code',{className:'uas-path'},state.instructions.path),!state.instructions.exists?h('p',{className:'uas-muted'},tr('notCreated')):null,h('textarea',{'aria-label':tr('instructions'),className:'uas-editor',value:text,onChange:event=>setText(event.target.value),spellCheck:false,disabled:!!busy}),h('p',{className:'uas-muted'},tr('limit')),h('div',{className:'uas-actions'},button(busy==='instructions'?tr('saving'):tr('save'),saveInstructions,!instructionDirty,true),button(tr('reload'),()=>{if(!dirty||window.confirm(tr('discard')))void load()}))),
42
+ card(tr('compression'),h('p',{className:'uas-muted'},tr('compressionHelp')),h('label',{className:'uas-field'},h('span',null,tr('compression')),h('select',{value:mode,onChange:event=>setMode(event.target.value),disabled:!!busy},h('option',{value:'follow'},tr('follow')),h('option',{value:'choose'},tr('choose')))),mode==='choose'?h(React.Fragment,null,h('label',{className:'uas-field'},h('span',null,tr('provider')),h('select',{value:provider,onChange:event=>{setProvider(event.target.value);setModel('')},disabled:!!busy},h('option',{value:''},'—'),...providers.map(item=>h('option',{key:item.id,value:item.id},item.name||item.id)))),h('label',{className:'uas-field'},h('span',null,tr('model')),h('input',{value:model,list:'uas-models',placeholder:tr('modelPlaceholder'),onChange:event=>setModel(event.target.value),disabled:!!busy})),h('datalist',{id:'uas-models'},...models.map(item=>h('option',{key:item.id,value:item.id},item.name||item.id))),providers.length===0?h('p',{className:'uas-muted'},tr('noProviders')):null):null,h('div',{className:'uas-actions'},button(busy==='compression'?tr('saving'):tr('saveCompression'),saveCompression,!selectionDirty||(mode==='choose'&&(!provider||!model)),true)),state.status?h('details',{className:'uas-diagnostics'},h('summary',null,tr('capability')),h('pre',null,JSON.stringify(state.status,null,2))):null),
43
+ card(tr('refresh'),h('p',{className:'uas-muted'},tr('refreshHelp')),button(tr('refreshButton'),()=>{if(window.confirm(tr(dirty?'refreshDirty':'refreshConfirm')))window.location.reload()}))
44
+ ))
45
+ }
46
+ const CSS = `
47
+ .uas-page { max-width: 880px; padding: 28px 32px 48px; color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-family),system-ui,sans-serif; }
48
+ .uas-page h2 { margin: 0 0 10px; font-size: 20px; font-weight: 600; } .uas-page h3 { margin: 0 0 12px; font-size: 16px; font-weight: 600; }
49
+ .uas-muted { color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.65; margin: 8px 0 14px; }
50
+ .uas-card { padding: 22px 0 26px; border-bottom: 1px solid var(--dsw-alias-border-l1); } .uas-card:last-child { border: none; }
51
+ .uas-path { display:block; overflow-wrap:anywhere; font-size:12px; margin:12px 0; color:var(--dsw-alias-label-secondary); }
52
+ .uas-editor { box-sizing: border-box; display:block; width:100%; min-height:260px; resize:vertical; padding:14px; line-height:1.6; font-family:ui-monospace,Consolas,monospace; font-size:13px; color:var(--dsw-alias-label-primary); background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l1); border-radius:10px; }
53
+ .uas-actions { display:flex; flex-wrap:wrap; gap:10px; margin-top:14px; }
54
+ .uas-button { border:1px solid var(--dsw-alias-border-l1); border-radius:8px; padding:8px 14px; background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary); font:inherit; font-size:13px; cursor:pointer; }
55
+ .uas-button:hover { background:var(--dsw-alias-interactive-bg-hover); } .uas-button:disabled { opacity:.5; cursor:default; } .uas-primary { color:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }
56
+ .uas-field { display:grid; grid-template-columns:130px minmax(0,1fr); align-items:center; gap:16px; margin:14px 0; font-size:13px; }
57
+ .uas-field input,.uas-field select { box-sizing:border-box; width:100%; padding:9px 11px; border:1px solid var(--dsw-alias-border-l1); border-radius:8px; color:var(--dsw-alias-label-primary); background:var(--dsw-alias-bg-layer-1); font:inherit; }
58
+ .uas-notice { padding:10px 14px; border:1px solid var(--dsw-alias-border-l1); border-radius:8px; font-size:13px; } .uas-error { color:var(--dsw-alias-state-error-primary); }
59
+ .uas-diagnostics { margin-top:14px; font-size:12px; color:var(--dsw-alias-label-secondary); } .uas-diagnostics pre { white-space:pre-wrap; overflow-wrap:anywhere; }
60
+ .uas-page button:focus-visible,.uas-page input:focus-visible,.uas-page select:focus-visible,.uas-page textarea:focus-visible { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:2px; }
61
+ @media(max-width:600px){.uas-page{padding:20px 16px}.uas-field{grid-template-columns:1fr;gap:7px}}
62
+ `
63
+ return {inject:['slots','locale'],apply(ctx){
64
+ ctx.effect(()=>ctx.locale.register(NS,{zh,en}),'user-agent-settings: translations')
65
+ ctx.effect(()=>ctx.slots.inject('settings.section',()=>ctx.slots.register({name:'settings.section',id:'user-agent-settings',order:45,label:()=>ctx.locale.bind(NS)('title'),locale:NS},Page)),'user-agent-settings: settings page')
66
+ }}
67
+ },
68
+ })
package/compaction.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Runtime-only compaction routing. Persistence belongs to the settings core.
3
+ *
4
+ * Native contract verified against dsh-compaction-basic: summarize is the sole
5
+ * customization hook and every automatic/manual/region path dispatches it.
6
+ * We never register llm/stream middleware, edit Loader/profile configuration,
7
+ * mutate AgentOptions/session headers, or change the live engine config.
8
+ *
9
+ * createCompactionAdapter(ctx, getSelection) returns synchronously:
10
+ * status(): detached diagnostic object (no model requests)
11
+ * refresh(): rescan live scopes; existing wrappers read selection per call
12
+ * validate(selection): Promise<normalized selection>; metadata-only check
13
+ * dispose(): Promise<void>; idempotent, restores owned method descriptors
14
+ * Selection is {provider, model} or null (follow each conversation's route).
15
+ * Changing the getter affects the next summary, never an in-flight summary.
16
+ */
17
+ const ORIGINAL = Symbol.for('cordis.original')
18
+ const OWNED = Symbol.for('user-agent-settings.compaction-adapter.v1')
19
+
20
+ function failure(code, message) { return Object.assign(new Error(message), { code }) }
21
+ function canonical(value) { return value?.[ORIGINAL] ?? value }
22
+ function normalize(selection) {
23
+ if (selection === null) return null
24
+ if (!selection || typeof selection !== 'object' || Array.isArray(selection) ||
25
+ Object.keys(selection).some(key => key !== 'provider' && key !== 'model') ||
26
+ typeof selection.provider !== 'string' || !selection.provider.trim() ||
27
+ typeof selection.model !== 'string' || !selection.model.trim()) {
28
+ throw failure('INVALID_COMPACTION_MODEL', 'Compaction model must be null or a non-empty {provider, model} pair.')
29
+ }
30
+ return Object.freeze({ provider: selection.provider, model: selection.model })
31
+ }
32
+
33
+ function inspectEngine(value) {
34
+ const engine = canonical(value)
35
+ if (!engine) return { engine: null, supported: false, reason: 'No compaction service is active in this scope.' }
36
+ let proto = engine
37
+ let native
38
+ while (proto && proto !== Object.prototype) {
39
+ const constructor = Object.getOwnPropertyDescriptor(proto, 'constructor')?.value
40
+ if (constructor?.name === 'BasicCompactionEngine') {
41
+ native = Object.getOwnPropertyDescriptor(proto, 'summarize')?.value
42
+ break
43
+ }
44
+ proto = Object.getPrototypeOf(proto)
45
+ }
46
+ const current = engine.summarize
47
+ const base = current?.[OWNED]?.original ?? current
48
+ if (typeof native !== 'function' || base !== native ||
49
+ !engine.config || !Array.isArray(engine.config.modelPolicies) ||
50
+ typeof engine.compactNow !== 'function' || typeof engine.compactRegion !== 'function' ||
51
+ typeof engine.compactIfNeeded !== 'function') {
52
+ return { engine, supported: false, reason: 'Custom/unknown compaction engine: only the native BasicCompactionEngine summarizer hook is supported.' }
53
+ }
54
+ const descriptor = Object.getOwnPropertyDescriptor(engine, 'summarize')
55
+ if (descriptor && !descriptor.configurable && !descriptor.writable) {
56
+ return { engine, supported: false, reason: 'The compaction summarizer is not replaceable.' }
57
+ }
58
+ if (!descriptor && !Object.isExtensible(engine)) {
59
+ return { engine, supported: false, reason: 'The compaction instance is not extensible.' }
60
+ }
61
+ return { engine, supported: true, original: base }
62
+ }
63
+
64
+ function summaryConfig(config, selection) {
65
+ const pair = {
66
+ summarizationProvider: selection?.provider ?? '',
67
+ summarizationModel: selection?.model ?? '',
68
+ }
69
+ // Exact model policies otherwise win over the top-level summarizer route.
70
+ // All pressure, retention, retry and output-cap policy values are preserved.
71
+ return Object.freeze({
72
+ ...config, ...pair,
73
+ modelPolicies: Object.freeze(config.modelPolicies.map(policy => Object.freeze({ ...policy, ...pair }))),
74
+ })
75
+ }
76
+
77
+ export function createCompactionAdapter(ctx, getSelection) {
78
+ if (typeof getSelection !== 'function') throw new TypeError('getSelection must be a function')
79
+ const records = new Map()
80
+ const patches = new Map()
81
+ const listeners = []
82
+ const rootKey = {}
83
+ let disposed = false
84
+ let disposal
85
+ let lastError = null
86
+ let summariesStarted = 0
87
+
88
+ function warn(error) {
89
+ lastError = error instanceof Error ? error.message : String(error)
90
+ try { ctx.logger?.warn?.('Compaction model adapter: ' + lastError) } catch {}
91
+ }
92
+ function getService(scope, name) { return scope?.get?.(name) }
93
+ function compactionFor(record) {
94
+ if (record.kind === 'agent') {
95
+ // Preset groups isolate compaction BELOW Agent.ctx; ctx.get cannot see
96
+ // them. This public resolver also returns a caller-traced service.
97
+ const presets = getService(ctx, 'agentPresets')
98
+ const service = presets?.serviceFor?.(record.agent, 'compaction')
99
+ if (service !== undefined) { record.source = 'preset'; return service }
100
+ }
101
+ record.source = record.kind === 'host' ? 'host' : 'agent'
102
+ return getService(record.scope, 'compaction')
103
+ }
104
+ function release(record) {
105
+ const patch = record.patch
106
+ record.patch = null
107
+ if (!patch || --patch.users > 0) return
108
+ patches.delete(patch.engine)
109
+ if (patch.engine.summarize !== patch.wrapper) return // never clobber a later owner
110
+ if (patch.descriptor) Object.defineProperty(patch.engine, 'summarize', patch.descriptor)
111
+ else delete patch.engine.summarize
112
+ }
113
+ function sync(record) {
114
+ if (disposed || record.stopped) return
115
+ let inspection
116
+ try { inspection = inspectEngine(compactionFor(record)) }
117
+ catch (error) { inspection = { engine: null, supported: false, reason: String(error.message ?? error) } }
118
+ if (record.patch && record.patch.engine === inspection.engine &&
119
+ inspection.engine.summarize === record.patch.wrapper) {
120
+ record.supported = true; record.reason = null; return
121
+ }
122
+ release(record)
123
+ record.supported = inspection.supported
124
+ record.reason = inspection.reason ?? null
125
+ if (!inspection.supported) return
126
+ let patch = patches.get(inspection.engine)
127
+ if (!patch) {
128
+ if (inspection.engine.summarize?.[OWNED]) {
129
+ record.supported = false
130
+ record.reason = 'Another compaction settings adapter already owns this instance.'
131
+ return
132
+ }
133
+ const { engine, original } = inspection
134
+ patch = { engine, original, descriptor: Object.getOwnPropertyDescriptor(engine, 'summarize'), users: 0 }
135
+ patch.wrapper = function (input, agent, signal) {
136
+ if (disposed) return Reflect.apply(original, this, [input, agent, signal])
137
+ const selection = normalize(getSelection())
138
+ const receiver = Object.create(this)
139
+ Object.defineProperty(receiver, 'config', { value: summaryConfig(this.config, selection) })
140
+ summariesStarted++
141
+ // Per-call receiver preserves the caller's Cordis ctx and cancellation.
142
+ // No mutation, await window, request middleware, or global LLM rewrite.
143
+ return Reflect.apply(original, receiver, [input, agent, signal])
144
+ }
145
+ Object.defineProperty(patch.wrapper, OWNED, { value: { original } })
146
+ try {
147
+ Object.defineProperty(engine, 'summarize', {
148
+ ...(patch.descriptor ?? { configurable: true, enumerable: false, writable: true }),
149
+ value: patch.wrapper,
150
+ })
151
+ } catch (error) {
152
+ record.supported = false; record.reason = String(error.message ?? error); warn(error); return
153
+ }
154
+ patches.set(engine, patch)
155
+ }
156
+ patch.users++
157
+ record.patch = patch
158
+ }
159
+ function watch(key, scope, kind) {
160
+ if (!scope || records.has(key) || disposed) return
161
+ const record = { scope, kind, agent: kind === 'agent' ? key : null, source: null, id: kind === 'agent' ? String(key.id) : null, stopped: false, patch: null, supported: false, reason: null, watcher: null, lifetime: null }
162
+ records.set(key, record)
163
+ const stop = () => {
164
+ if (record.stopped) return
165
+ record.stopped = true
166
+ records.delete(key)
167
+ release(record)
168
+ const watcher = record.watcher; record.watcher = null
169
+ return watcher?.dispose?.()
170
+ }
171
+ record.stop = stop
172
+ sync(record) // cover already-loaded agents before asynchronous inject setup
173
+ try {
174
+ if (typeof scope.inject === 'function') {
175
+ record.watcher = scope.inject(['compaction'], function compactionSettingsScope() {
176
+ sync(record)
177
+ return () => release(record)
178
+ })
179
+ // Fiber is thenable: observe startup failures, not a detached rejection.
180
+ if (record.watcher?.then) Promise.resolve(record.watcher).catch(warn)
181
+ }
182
+ // Agent scope and plugin both own this registration (practices.md).
183
+ if (kind === 'agent' && typeof scope.effect === 'function') {
184
+ record.lifetime = scope.effect(() => stop, 'user-agent-settings: compaction scope')
185
+ }
186
+ } catch (error) { warn(error) }
187
+ }
188
+ function refresh() {
189
+ if (disposed) return status()
190
+ try {
191
+ watch(rootKey, ctx, 'host')
192
+ const agents = getService(ctx, 'agents')
193
+ const live = agents?.list?.() ?? []
194
+ const keys = new Set(live)
195
+ for (const agent of live) watch(agent, agent.ctx, 'agent')
196
+ for (const [key, record] of records) {
197
+ if (record.kind === 'agent' && !keys.has(key)) {
198
+ const result = record.stop(); result?.catch?.(warn)
199
+ } else sync(record)
200
+ }
201
+ } catch (error) { warn(error) }
202
+ return status()
203
+ }
204
+ function status() {
205
+ let selection = null
206
+ let selectionError = null
207
+ try { selection = normalize(getSelection()) } catch (error) { selectionError = error.message }
208
+ const scopes = [...records.values()].map(record => ({
209
+ kind: record.kind, source: record.source, sessionId: record.id, supported: record.supported, reason: record.reason,
210
+ }))
211
+ const active = scopes.filter(scope => scope.supported).length
212
+ const unsupported = scopes.filter(scope => !scope.supported && !scope.reason?.startsWith('No compaction service'))
213
+ return {
214
+ available: !disposed && active > 0 && unsupported.length === 0 && !selectionError,
215
+ mode: selection === null ? 'follow-conversation' : 'fixed',
216
+ selection, disposed, mechanism: 'native-summarize-hook',
217
+ coverage: ['automatic', 'context-overflow', 'manual', 'region'],
218
+ activeEngines: patches.size, activeScopes: active, scopes, summariesStarted,
219
+ limitations: unsupported.map(scope => ({ sessionId: scope.sessionId, reason: scope.reason })),
220
+ error: selectionError ?? lastError,
221
+ }
222
+ }
223
+ async function validate(value) {
224
+ if (disposed) throw failure('COMPACTION_ADAPTER_DISPOSED', 'Compaction adapter is disposed.')
225
+ const selection = normalize(value)
226
+ const report = refresh()
227
+ if (selection !== null && (!report.activeEngines || report.limitations.length)) {
228
+ throw failure('COMPACTION_UNSUPPORTED', report.limitations[0]?.reason ?? 'No supported native compaction service is active.')
229
+ }
230
+ if (selection === null) return null
231
+ const llm = getService(ctx, 'llm')
232
+ const providers = await llm?.listProviders?.()
233
+ if (!providers?.some(provider => provider.id === selection.provider)) {
234
+ throw failure('COMPACTION_PROVIDER_UNAVAILABLE', 'The selected compaction provider is not registered.')
235
+ }
236
+ const models = await llm.listModels(selection.provider)
237
+ if (!models.some(model => model.id === selection.model)) {
238
+ throw failure('COMPACTION_MODEL_UNAVAILABLE', 'The selected compaction model is not in the provider model catalog.')
239
+ }
240
+ return selection
241
+ }
242
+ function dispose() {
243
+ if (disposal) return disposal
244
+ disposed = true
245
+ const pending = []
246
+ for (const off of listeners.splice(0)) {
247
+ try { const result = off(); if (result?.then) pending.push(result) } catch (error) { warn(error) }
248
+ }
249
+ for (const record of [...records.values()]) {
250
+ try {
251
+ const result = record.stop(); if (result?.then) pending.push(result)
252
+ const lifetime = record.lifetime?.(); if (lifetime?.then) pending.push(lifetime)
253
+ } catch (error) { warn(error) }
254
+ }
255
+ disposal = Promise.allSettled(pending).then(() => undefined)
256
+ return disposal
257
+ }
258
+ if (typeof ctx.on === 'function') {
259
+ listeners.push(ctx.on('agent/created', ({ agent }) => { watch(agent, agent.ctx, 'agent') }))
260
+ listeners.push(ctx.on('agent/disposed', ({ agent }) => {
261
+ const record = records.get(agent); const result = record?.stop(); result?.catch?.(warn)
262
+ }))
263
+ listeners.push(ctx.on('app-boot/config-reload', () => { refresh() }))
264
+ listeners.push(ctx.on('agent-preset/selected', () => { refresh() }))
265
+ }
266
+ refresh()
267
+ return { status, refresh, validate, dispose }
268
+ }
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: user-agent-settings
3
+ name: 'dsh-asuta-user-agent-settings'
4
+ config: {}
package/core.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+
6
+ export const MAX_INSTRUCTION_BYTES = 128 * 1024
7
+ // Match the native instruction loader without relaxing editor/write protections.
8
+ export const MAX_RUNTIME_INSTRUCTION_BYTES = 1024 * 1024
9
+ export const digest = text => createHash('sha256').update(text).digest('hex')
10
+ export const fault = (code, message) => Object.assign(new Error(message), { code })
11
+ export function homePath(env = process.env) {
12
+ const value = env.DSH_HOME?.trim() || path.join(os.homedir(), '.dsh')
13
+ return path.resolve(value === '~' ? os.homedir() : /^[~][\\/]/.test(value) ? path.join(os.homedir(), value.slice(2)) : value)
14
+ }
15
+ async function readText(file, optional = false, { runtime = false } = {}) {
16
+ const limit = runtime ? MAX_RUNTIME_INSTRUCTION_BYTES : MAX_INSTRUCTION_BYTES
17
+ try {
18
+ const stat = runtime ? await fs.stat(file) : await fs.lstat(file)
19
+ if (!stat.isFile() || (!runtime && stat.isSymbolicLink())) throw fault('INVALID_FILE', 'The settings target must be a regular file' + (runtime ? '' : ', not a symbolic link'))
20
+ const tooLarge = () => fault('TOO_LARGE', runtime ? 'File exceeds the 1 MiB runtime instruction limit' : 'File exceeds the 128 KiB editor limit')
21
+ if (stat.size > limit) throw tooLarge()
22
+ const content = await fs.readFile(file)
23
+ if (content.length > limit) throw tooLarge()
24
+ return { exists: true, text: content.toString('utf8') }
25
+ } catch (error) { if (optional && error.code === 'ENOENT') return { exists: false, text: '' }; throw error }
26
+ }
27
+ async function atomicWrite(file, text) {
28
+ await fs.mkdir(path.dirname(file), { recursive: true })
29
+ const temporary = file + '.' + randomUUID() + '.tmp'
30
+ try { await fs.writeFile(temporary, text, { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, file) }
31
+ finally { await fs.rm(temporary, { force: true }) }
32
+ }
33
+ export function createSettingsCore({ home = homePath(), llm, adapter } = {}) {
34
+ const instructionFile = path.join(home, 'AGENTS.md')
35
+ const selectionFile = path.join(home, 'plugin-data', 'user-agent-settings', 'settings.json')
36
+ let selection = null, selectionRevision = digest('missing'), tail = Promise.resolve()
37
+ const serial = operation => { const next = tail.then(operation); tail = next.catch(() => {}); return next }
38
+ async function load() {
39
+ const saved = await readText(selectionFile, true)
40
+ if (saved.exists) { const parsed = JSON.parse(saved.text); selection = normalizeSelection(parsed.compaction); selectionRevision = digest(saved.text) }
41
+ else { selection = null; selectionRevision = digest('missing') }
42
+ return selection
43
+ }
44
+ function normalizeSelection(value) {
45
+ if (value === null || value === undefined) return null
46
+ if (!value || typeof value !== 'object' || typeof value.provider !== 'string' || typeof value.model !== 'string' || !value.provider.trim() || !value.model.trim() || value.provider.length > 256 || value.model.length > 512) throw fault('BAD_SELECTION', 'Choose a provider and model, or follow the conversation model')
47
+ return { provider: value.provider.trim(), model: value.model.trim() }
48
+ }
49
+ async function instructions({ runtime = false } = {}) {
50
+ const content = await readText(instructionFile, true, { runtime })
51
+ return { path: instructionFile, ...content, revision: digest((content.exists ? 'present:' : 'missing:') + content.text), maxBytes: runtime ? MAX_RUNTIME_INSTRUCTION_BYTES : MAX_INSTRUCTION_BYTES }
52
+ }
53
+ async function saveInstructions({ text, revision }) {
54
+ return serial(async () => {
55
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_INSTRUCTION_BYTES) throw fault('TOO_LARGE', 'Instruction text must be UTF-8 text up to 128 KiB')
56
+ const current = await instructions()
57
+ if (typeof revision !== 'string' || revision !== current.revision) throw fault('CONFLICT', 'Global instructions changed elsewhere. Reload before saving')
58
+ if (current.exists && current.text === text) return current
59
+ await atomicWrite(instructionFile, text)
60
+ return instructions()
61
+ })
62
+ }
63
+ async function saveCompaction({ compaction, revision }) {
64
+ return serial(async () => {
65
+ const current = await readText(selectionFile, true)
66
+ const actualRevision = current.exists ? digest(current.text) : digest('missing')
67
+ if (revision !== actualRevision) throw fault('CONFLICT', 'Compression settings changed elsewhere. Reload before saving')
68
+ const next = normalizeSelection(compaction)
69
+ await adapter?.validate(next)
70
+ if (next && !adapter) await llm.resolveModelInfo(next.provider, next.model)
71
+ const text = JSON.stringify({ version: 1, compaction: next }, null, 2) + '\n'
72
+ await atomicWrite(selectionFile, text)
73
+ selection = next; selectionRevision = digest(text)
74
+ adapter?.refresh?.()
75
+ return { compaction: selection, revision: selectionRevision, status: adapter?.status?.() ?? null }
76
+ })
77
+ }
78
+ async function models() {
79
+ const providers = llm.listProviders()
80
+ const entries = await Promise.all(providers.map(async provider => {
81
+ try { return { ...provider, models: (await llm.listModels(provider.id)).map(model => ({ id: model.id, name: model.name })) } }
82
+ catch (error) { return { ...provider, models: [], error: error.message } }
83
+ }))
84
+ return entries
85
+ }
86
+ return { load, instructions, saveInstructions, saveCompaction, models, getSelection: () => selection, setAdapter: value => { adapter = value }, snapshot: () => serial(async () => { await load(); return { instructions: await instructions(), compaction: selection, revision: selectionRevision, status: adapter?.status?.() ?? null } }) }
87
+ }
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect x="4" y="4" width="56" height="56" rx="15" fill="#164e63"/><path d="M18 17h22l7 7v24H18z" fill="none" stroke="#ecfeff" stroke-width="3" stroke-linejoin="round"/><path d="M39 17v9h8M24 33h17M24 39h10" fill="none" stroke="#67e8f9" stroke-width="3" stroke-linecap="round"/><circle cx="45" cy="45" r="10" fill="#0891b2"/><path d="M45 39v12M39 45h12" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
package/index.js ADDED
@@ -0,0 +1,88 @@
1
+ import { createSettingsCore, MAX_INSTRUCTION_BYTES } from './core.js?v=1.0.3'
2
+ import { createCompactionAdapter } from './compaction.js?v=1.0.1'
3
+ import { createGlobalInstructionsAdapter } from './instructions.js?v=1.0.2'
4
+ import { createSettingsToolExecutor } from './permissions.js?v=1.0.3'
5
+
6
+ export const inject = ['webServer', 'llm', 'agents', 'agentPresets', 'systemPrompt', 'connection', 'sandboxPolicy']
7
+ const PREFIX = '/__user-agent-settings'
8
+ // JSON can encode one control byte as six bytes (\u0000). Core still
9
+ // validates the decoded UTF-8 text against the independent 128 KiB limit.
10
+ const MAX_BODY = MAX_INSTRUCTION_BYTES * 6 + 16 * 1024
11
+ async function readJson(req) {
12
+ const chunks = []; let size = 0
13
+ for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY) throw Object.assign(new Error('Request too large'), { code: 'TOO_LARGE' }); chunks.push(chunk) }
14
+ return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
15
+ }
16
+ function respond(res, status, value) { res.statusCode = status; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store'); res.end(JSON.stringify(value)) }
17
+ export async function apply(ctx) {
18
+ const core = createSettingsCore({ llm: ctx.llm })
19
+ await core.load()
20
+ let globalInstructions = null, instructionReadError = null
21
+ async function refreshGlobalInstructions() {
22
+ try {
23
+ globalInstructions = await core.instructions({ runtime: true })
24
+ instructionReadError = null
25
+ } catch (error) {
26
+ // Do not inject stale preferences or let optional fallback I/O gate every
27
+ // main/child request. Native presets keep their own instruction loader.
28
+ globalInstructions = null
29
+ const diagnostic = { code: error.code ?? 'INSTRUCTION_READ_FAILED', message: String(error.message ?? error) }
30
+ if (diagnostic.code !== instructionReadError?.code || diagnostic.message !== instructionReadError?.message) {
31
+ try { ctx.logger?.warn?.('Global instructions refresh: ' + diagnostic.message) } catch {}
32
+ }
33
+ instructionReadError = diagnostic
34
+ }
35
+ }
36
+ await refreshGlobalInstructions()
37
+ const instructionAdapter = await createGlobalInstructionsAdapter(ctx, () => globalInstructions?.text.trim() ? `Global user instructions from ${globalInstructions.path}. Apply as user preferences; system, developer, direct user, project and role-specific instructions retain precedence.\n\n${globalInstructions.text}` : '')
38
+ ctx.effect(() => () => instructionAdapter.dispose(), 'user-agent-settings: dispose global instructions fallback')
39
+ ctx.on('agent/pre-step', async (_step, next) => { await refreshGlobalInstructions(); return next() })
40
+ const adapter = createCompactionAdapter(ctx, core.getSelection)
41
+ core.setAdapter(adapter)
42
+ ctx.effect(() => () => adapter.dispose(), 'user-agent-settings: dispose compaction routing')
43
+ async function operate(args = {}) {
44
+ switch (args.action) {
45
+ case 'read': {
46
+ const presets = await ctx.get('agentPresets')?.compositionInventory?.() ?? []
47
+ const instructionLoaders = presets.map(preset => ({ id: preset.id, name: preset.name ?? preset.id, default: preset.isDefault, nativeGlobalFile: preset.rows.some(row => row.enabled === true && row.moduleName === '@deepseek-ai/dsh-agent-instructions') }))
48
+ await refreshGlobalInstructions()
49
+ const snapshot = await core.snapshot()
50
+ return { ok: true, ...snapshot, instructionLoaders, globalInstructionStatus: { ...instructionAdapter.status(), instructionReadError }, desktopNavigationStatus: ctx.get('desktopNavigationStatus')?.read?.() ?? null, clientAssets: ctx.get('clientModules')?.graph().entries.filter(entry => ['dsh-asuta-user-agent-settings', 'dsh-asuta-desktop-navigation'].includes(entry.id)) ?? [] }
51
+ }
52
+ case 'models': return { ok: true, providers: await core.models() }
53
+ case 'save_instructions': {
54
+ const instructions = await core.saveInstructions(args)
55
+ await refreshGlobalInstructions()
56
+ return { ok: true, instructions }
57
+ }
58
+ case 'save_compaction': return { ok: true, ...(await core.saveCompaction(args)) }
59
+ default: throw Object.assign(new Error('Unknown action'), { code: 'BAD_ACTION' })
60
+ }
61
+ }
62
+ ctx.effect(() => ctx.provide('userAgentSettings', { operate }), 'user-agent-settings: shared operations')
63
+ ctx.effect(() => ctx.webServer.register({
64
+ kind: 'exact', path: PREFIX + '/operation',
65
+ async handler(req, res) {
66
+ const rejection = ctx.connection.requestRejection(req)
67
+ if (rejection !== undefined) return respond(res, rejection, { ok: false, code: 'FORBIDDEN', error: 'DSH authentication required' })
68
+ if (String(req.headers['x-user-agent-settings'] ?? '') !== '1') return respond(res, 403, { ok: false, code: 'FORBIDDEN', error: 'Missing settings request header' })
69
+ const origin = req.headers.origin
70
+ if (origin && origin !== ctx.webServer.getOrigin()) return respond(res, 403, { ok: false, code: 'FORBIDDEN', error: 'Cross-origin settings requests are not allowed' })
71
+ if (req.method !== 'POST') return respond(res, 405, { ok: false, error: 'POST only' })
72
+ try { respond(res, 200, await operate(await readJson(req))) }
73
+ catch (error) { respond(res, error.code === 'CONFLICT' ? 409 : 400, { ok: false, code: error.code ?? 'SETTINGS_FAILED', error: error.message }) }
74
+ },
75
+ }), 'user-agent-settings: authenticated settings route')
76
+ ctx.effect(() => ctx.get('tools')?.register({
77
+ name: 'user_agent_settings', deferLoading: true,
78
+ description: 'Read or edit the native global AGENTS instructions and choose the model used only for context compression across main agents and subagents. Saves require the revision returned by read and danger-full-access permission or native approval for this call; they affect other sessions. Browser refresh is user-only.',
79
+ parameters: { type: 'object', properties: {
80
+ action: { type: 'string', enum: ['read', 'models', 'save_instructions', 'save_compaction'] },
81
+ revision: { type: 'string', description: 'For instruction saves use instructions.revision; for compression saves use the top-level revision from read.' },
82
+ text: { type: 'string', description: 'Complete global AGENTS.md contents for save_instructions, up to 128 KiB UTF-8.' },
83
+ compaction: { description: 'For save_compaction: null follows each conversation model; otherwise provide its registered provider and model.', oneOf: [{ type: 'null' }, { type: 'object', properties: { provider: { type: 'string' }, model: { type: 'string' } }, required: ['provider', 'model'], additionalProperties: false }] },
84
+ }, required: ['action'], additionalProperties: false },
85
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
86
+ execute: createSettingsToolExecutor(ctx, operate),
87
+ }), 'user-agent-settings: agent settings operations')
88
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Fill the native global AGENTS gap for presets without agent-instructions.
3
+ * No file access or profile writes; the parent owns the synchronous text cache.
4
+ *
5
+ * Public API: await createGlobalInstructionsAdapter(ctx, getInstructionsText)
6
+ * -> { refresh(): Promise<status>, status(): plain diagnostics,
7
+ * dispose(): Promise<void> }.
8
+ *
9
+ * Native assembleContextFor(agent) supplies scope=agent. Use that exact public
10
+ * Agent/context relationship and agentPresets.composedPreset(), plus exact
11
+ * acquireScope() keys for cold assembly. No private scope symbols or imports.
12
+ */
13
+ const NATIVE_LOADER = '@deepseek-ai/dsh-agent-instructions'
14
+ const CONTEXT_NAME = 'user-agent-settings.global-instructions'
15
+
16
+ export async function createGlobalInstructionsAdapter(ctx, getInstructionsText) {
17
+ if (typeof getInstructionsText !== 'function') throw new TypeError('getInstructionsText must be a function')
18
+ const presets = ctx.get('agentPresets')
19
+ const prompt = ctx.get('systemPrompt')
20
+ if (!presets?.compositionInventory || !presets?.acquireScope || !presets?.composedPreset || !prompt?.context) {
21
+ throw new Error('Global instructions require the native agentPresets and systemPrompt services.')
22
+ }
23
+ let rows = new Map()
24
+ let leases = new Map()
25
+ let pending = null
26
+ let dirty = false
27
+ let disposed = false
28
+ let disposal = null
29
+ let lastError = null
30
+ let generation = 0
31
+ let unregisterContext = null
32
+ const listeners = []
33
+
34
+ function warn(error) {
35
+ lastError = error instanceof Error ? error.message : String(error)
36
+ try { ctx.logger?.warn?.('Global instructions adapter: ' + lastError) } catch {}
37
+ }
38
+ function text() {
39
+ const value = getInstructionsText()
40
+ if (typeof value !== 'string') throw new TypeError('getInstructionsText must return a string')
41
+ return value.trim() ? value : ''
42
+ }
43
+ async function release(lease) {
44
+ if (lease?.[Symbol.asyncDispose]) await lease[Symbol.asyncDispose]()
45
+ else if (lease?.dispose) await lease.dispose()
46
+ }
47
+ function modeOf(inventory) {
48
+ const native = (inventory.rows ?? []).filter(row => row.moduleName === NATIVE_LOADER)
49
+ if (native.some(row => row.enabled === true)) return 'native'
50
+ // Conditional ownership is uncertain: suppress our copy instead of risking
51
+ // two copies if the native loader activates. Report it in status.
52
+ if (native.some(row => row.enabled === 'conditional')) return 'uncertain'
53
+ return 'fallback'
54
+ }
55
+ async function rebuild() {
56
+ const inventory = await presets.compositionInventory()
57
+ const nextRows = new Map()
58
+ const nextLeases = new Map()
59
+ try {
60
+ for (const entry of inventory) {
61
+ const mode = modeOf(entry)
62
+ const row = { id: entry.id, mode, broken: typeof entry.broken === 'string' ? entry.broken : null }
63
+ nextRows.set(entry.id, row)
64
+ if (mode !== 'fallback' || row.broken) continue
65
+ const lease = await presets.acquireScope(entry.id)
66
+ if (disposed) { await release(lease); break }
67
+ nextLeases.set(entry.id, lease)
68
+ }
69
+ } catch (error) {
70
+ await Promise.allSettled([...nextLeases.values()].map(release))
71
+ throw error
72
+ }
73
+ if (disposed) {
74
+ await Promise.allSettled([...nextLeases.values()].map(release))
75
+ return
76
+ }
77
+ const previous = leases
78
+ rows = nextRows
79
+ leases = nextLeases
80
+ lastError = null
81
+ generation++
82
+ const results = await Promise.allSettled([...previous.values()].map(release))
83
+ for (const result of results) if (result.status === 'rejected') warn(result.reason)
84
+ }
85
+ function refresh() {
86
+ if (disposed) return Promise.resolve(status())
87
+ dirty = true
88
+ if (pending) return pending
89
+ pending = (async () => {
90
+ while (dirty && !disposed) { dirty = false; await rebuild() }
91
+ return status()
92
+ })().catch(error => { warn(error); throw error }).finally(() => { pending = null })
93
+ return pending
94
+ }
95
+ function fallbackFor(scope) {
96
+ if (scope === undefined) return true
97
+ // Cold presentation acquires and supplies the revision key itself.
98
+ for (const [id, lease] of leases) if (scope === lease.key) return rows.get(id)?.mode === 'fallback'
99
+ // Live main/subagents are their own native prompt assembly scope keys.
100
+ // Reading the composed preset through its public context API is crucial:
101
+ // Agent.ctx.get cannot resolve the isolated preset composition.
102
+ if (scope && typeof scope === 'object' && scope.ctx) {
103
+ const id = presets.composedPreset(scope.ctx)
104
+ return id !== undefined && rows.get(id)?.mode === 'fallback' && !rows.get(id)?.broken
105
+ }
106
+ return false
107
+ }
108
+ function contextText(assembly = {}) {
109
+ if (disposed || !fallbackFor(assembly.scope)) return ''
110
+ return text()
111
+ }
112
+ function status() {
113
+ return {
114
+ available: !disposed && unregisterContext !== null,
115
+ mechanism: 'required-native-prompt-context',
116
+ contextName: CONTEXT_NAME,
117
+ generation, disposed, pending: pending !== null,
118
+ presets: [...rows.values()].map(row => ({
119
+ id: row.id, nativeGlobalFile: row.mode === 'native',
120
+ fallback: row.mode === 'fallback' && !row.broken,
121
+ leaseActive: leases.has(row.id), uncertain: row.mode === 'uncertain', broken: row.broken,
122
+ })),
123
+ limitations: [...rows.values()].filter(row => row.mode === 'uncertain' || row.broken).map(row => ({
124
+ id: row.id, reason: row.broken ?? 'Conditional native instruction loader: fallback suppressed to prevent duplication.',
125
+ })),
126
+ error: lastError,
127
+ }
128
+ }
129
+ async function readyFor(agent) {
130
+ if (pending) await pending
131
+ const id = agent?.ctx ? presets.composedPreset(agent.ctx) : undefined
132
+ if (id !== undefined && !rows.has(id)) await refresh()
133
+ }
134
+ function scheduleRefresh() { refresh().catch(() => {}) }
135
+ function dispose() {
136
+ if (disposal) return disposal
137
+ disposed = true
138
+ const removals = listeners.splice(0)
139
+ if (unregisterContext) { removals.push(unregisterContext); unregisterContext = null }
140
+ disposal = (async () => {
141
+ const removed = await Promise.allSettled(removals.map(off => Promise.resolve().then(off)))
142
+ for (const result of removed) if (result.status === 'rejected') warn(result.reason)
143
+ if (pending) await pending.catch(() => {})
144
+ const previous = leases; leases = new Map()
145
+ const released = await Promise.allSettled([...previous.values()].map(release))
146
+ for (const result of released) if (result.status === 'rejected') warn(result.reason)
147
+ })()
148
+ return disposal
149
+ }
150
+ try {
151
+ listeners.push(ctx.on('agent/created', async ({ agent }) => { await refresh(); await readyFor(agent) }))
152
+ listeners.push(ctx.on('agent/pre-step', async ({ agent }, next) => { await readyFor(agent); return next() }, { prepend: true }))
153
+ listeners.push(ctx.on('app-boot/config-reload', scheduleRefresh))
154
+ listeners.push(ctx.on('agent-preset/selected', scheduleRefresh))
155
+ await refresh()
156
+ unregisterContext = prompt.context({
157
+ name: CONTEXT_NAME, order: 100, text: contextText, required: true, interpolate: false,
158
+ })
159
+ } catch (error) {
160
+ await dispose()
161
+ throw error
162
+ }
163
+ return { refresh, status, dispose }
164
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Global Agent Settings",
4
+ "description": "Edit global AGENTS instructions, choose a context compression model, and reload the frontend."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Agent 全局设置",
4
+ "description": "编辑所有工作区共用的原生 AGENTS 指令、指定上下文压缩模型,并快速刷新前端。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "dsh-asuta-user-agent-settings",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "peerDependencies": {
5
+ "@deepseek-ai/dsh-sandbox": "0.2.2-rc.2"
6
+ },
7
+ "type": "module",
8
+ "description": "Edit native global Agent instructions, choose a compression model, and refresh the frontend from Settings.",
9
+ "main": "index.js",
10
+ "exports": {
11
+ ".": "./index.js",
12
+ "./client": "./client.js",
13
+ "./package.json": "./package.json",
14
+ "./locale/*.json": "./locale/*.json",
15
+ "./icon": "./icon.svg",
16
+ "./cordis.patch.yml": "./cordis.patch.yml"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "client.js",
21
+ "core.js",
22
+ "compaction.js",
23
+ "instructions.js",
24
+ "permissions.js",
25
+ "cordis.patch.yml",
26
+ "locale/*.json",
27
+ "icon.svg",
28
+ "README.md",
29
+ "LICENSE*",
30
+ "NOTICE*",
31
+ "THIRD_PARTY_NOTICES.md"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "immediately": true,
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-locale"
42
+ ]
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/Asuta/dsh-cdx-plus.git",
51
+ "directory": "packages/user-agent-settings"
52
+ },
53
+ "homepage": "https://github.com/Asuta/dsh-cdx-plus/tree/main/packages/user-agent-settings",
54
+ "bugs": {
55
+ "url": "https://github.com/Asuta/dsh-cdx-plus/issues"
56
+ },
57
+ "keywords": [
58
+ "dsh-plugin",
59
+ "deepseek-harness"
60
+ ],
61
+ "engines": {
62
+ "node": "^22.19.0 || >=24",
63
+ "dsh": "0.2.2-rc.2"
64
+ },
65
+ "license": "MIT",
66
+ "peerDependenciesMeta": {
67
+ "@deepseek-ai/dsh-sandbox": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "icon": "./icon.svg"
72
+ }
package/permissions.js ADDED
@@ -0,0 +1,32 @@
1
+ const SAVE_ACTIONS = new Set(['save_instructions', 'save_compaction'])
2
+ const loadNativeSandbox = () => import('@deepseek-ai/dsh-sandbox')
3
+
4
+ // UI saves are authenticated user actions. Model saves must additionally honor
5
+ // the actual caller's session (including delegated child policy), like the
6
+ // native plugin_manager tool; never reuse the UI's trusted write path directly.
7
+ export function createSettingsToolExecutor(ctx, operate, loadSandbox = loadNativeSandbox) {
8
+ return async (args, exec) => {
9
+ if (SAVE_ACTIONS.has(args?.action)) {
10
+ if (!exec?.signal || !ctx.sandboxPolicy?.resolve) {
11
+ throw Object.assign(new Error('Global settings saves require a caller execution context and sandbox policy.'), { code: 'PERMISSION_DENIED' })
12
+ }
13
+ exec.signal.throwIfAborted()
14
+ const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
15
+ if (!['read-only', 'workspace-write', 'danger-full-access'].includes(policy?.mode)) {
16
+ throw Object.assign(new Error('Cannot determine the caller permission mode.'), { code: 'PERMISSION_DENIED' })
17
+ }
18
+ if (policy.mode !== 'danger-full-access') {
19
+ // A missing native helper/approver or a denied/cancelled approval must
20
+ // reject the save. Import lazily so read-only discovery needs no helper.
21
+ const { approveEscalation } = await loadSandbox()
22
+ await approveEscalation({
23
+ requestedMode: 'danger-full-access', effectiveMode: policy.mode,
24
+ subject: 'global Agent settings save',
25
+ justification: `user_agent_settings ${args.action}. This writes global DSH settings outside the workspace and affects other sessions.`,
26
+ }, { approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId, toolName: 'user_agent_settings', signal: exec.signal })
27
+ }
28
+ exec.signal.throwIfAborted()
29
+ }
30
+ return JSON.parse(JSON.stringify(await operate(args)))
31
+ }
32
+ }