dsh-plan-completion 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes to this project are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: SemVer.
5
5
 
6
+ ## [0.1.6] - 2026-11-02
7
+
8
+ ### Added
9
+ - Model settings page registered at `settings.plugins.tab` (Settings → Plugins → 计划补全):
10
+ radio group for `default` / `fixed` / `auto` model policy, Provider/Model ID inputs for fixed
11
+ mode, a `modelCalls` toggle, current host-default route hint, and save/status feedback.
12
+ - Host settings route `GET/POST /plan-completion/settings` (authenticated) persists the policy to
13
+ `<storageDir>/settings.json` and loads it on apply; `pickModel` honours `default`/`fixed`/`auto`.
14
+ - Client transport test for the settings page (GET/POST, failure handling).
15
+ - Dev-only suggestion log lives outside the release: `research/suggestion-log-local/` keeps a local
16
+ copy for tuning; the published plugin does not contain or enable suggestion-content logging.
17
+
18
+ ### Changed
19
+ - `npm test` now reports **138/138** tests.
20
+
21
+ ## [0.1.5] - 2026-11-02
22
+
23
+ ### Fixed
24
+ - Detect a plan that stopped tracking the conversation: after **two consecutive `op=none`**
25
+ generation outcomes (e.g. an edited plan whose coarse step needs splitting), the published
26
+ suggestion becomes `/replan 按最近进展重排计划,把已完成项标记完成` and the plan is marked
27
+ stale, so the user can fix the plan with one Tab fill instead of seeing a repeated stale hint.
28
+ - `/reset` and forced `/replan` clear the none-streak counter.
29
+
30
+ ### Changed
31
+ - `npm test` now reports **133/133** tests.
32
+
6
33
  ## [0.1.4] - 2026-11-02
7
34
 
8
35
  ### Fixed
@@ -124,3 +151,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: Se
124
151
  - Debug endpoints (`theme`, `experiment`, `autojob`, `steps`, `reset`) are only registered when
125
152
  `enableDebugRoutes: true`; production endpoints are `state`, `debug`, `diag`, `replan`, `warm`.
126
153
  - No credentials are stored in the plugin; model calls use the host `llm` service.
154
+
package/client.js CHANGED
@@ -105,7 +105,22 @@ window.__ModuleLoader__.load({
105
105
  'verify.clean': '未发现明显差异。',
106
106
  'verify.next': '建议:{value}',
107
107
  'verify.failed': '核对失败:{value}',
108
- 'verify.no-session': '当前还没有可用会话'
108
+ 'verify.no-session': '当前还没有可用会话',
109
+ 'settings.title': '计划补全',
110
+ 'settings.model': '模型选择',
111
+ 'settings.policy.default': '跟随宿主默认',
112
+ 'settings.policy.fixed': '固定模型',
113
+ 'settings.policy.auto': '自动(优先非推理)',
114
+ 'settings.provider': 'Provider',
115
+ 'settings.modelId': 'Model ID',
116
+ 'settings.modelCalls': '允许本插件调用模型',
117
+ 'settings.defaultRoute': '宿主默认:{value}',
118
+ 'settings.save': '保存',
119
+ 'settings.saving': '保存中…',
120
+ 'settings.saved': '已保存',
121
+ 'settings.loading': '加载中…',
122
+ 'settings.error': '设置加载失败',
123
+ 'settings.saveError': '保存失败'
109
124
  },
110
125
  en: {
111
126
  'entry.aria': 'Open the session plan panel',
@@ -148,7 +163,22 @@ window.__ModuleLoader__.load({
148
163
  'verify.clean': 'No obvious mismatches found.',
149
164
  'verify.next': 'Next: {value}',
150
165
  'verify.failed': 'Verification failed: {value}',
151
- 'verify.no-session': 'No session available yet'
166
+ 'verify.no-session': 'No session available yet',
167
+ 'settings.title': 'Plan Completion',
168
+ 'settings.model': 'Model selection',
169
+ 'settings.policy.default': 'Follow host default',
170
+ 'settings.policy.fixed': 'Fixed model',
171
+ 'settings.policy.auto': 'Auto (prefer non-reasoning)',
172
+ 'settings.provider': 'Provider',
173
+ 'settings.modelId': 'Model ID',
174
+ 'settings.modelCalls': 'Allow this plugin to call models',
175
+ 'settings.defaultRoute': 'Host default: {value}',
176
+ 'settings.save': 'Save',
177
+ 'settings.saving': 'Saving…',
178
+ 'settings.saved': 'Saved',
179
+ 'settings.loading': 'Loading…',
180
+ 'settings.error': 'Failed to load settings',
181
+ 'settings.saveError': 'Save failed'
152
182
  }
153
183
  };
154
184
 
@@ -787,6 +817,52 @@ window.__ModuleLoader__.load({
787
817
  });
788
818
  }
789
819
 
820
+ /**
821
+ * Host settings used by the settings page (model policy + modelCalls).
822
+ */
823
+ function fetchSettings() {
824
+ try {
825
+ return Promise.resolve(fetch('/plan-completion/settings', {
826
+ method: 'GET',
827
+ credentials: 'same-origin',
828
+ headers: { accept: 'application/json' }
829
+ }))
830
+ .then(function readResponse(response) {
831
+ return response && response.ok === true ? Promise.resolve(response.json()) : null;
832
+ })
833
+ .then(function parsed(json) {
834
+ return json && typeof json === 'object' ? json : null;
835
+ })
836
+ .catch(function networkError() {
837
+ return null;
838
+ });
839
+ } catch (error) {
840
+ return Promise.resolve(null);
841
+ }
842
+ }
843
+
844
+ function saveSettings(payload) {
845
+ try {
846
+ return Promise.resolve(fetch('/plan-completion/settings', {
847
+ method: 'POST',
848
+ credentials: 'same-origin',
849
+ headers: { 'content-type': 'application/json' },
850
+ body: JSON.stringify(payload || {})
851
+ }))
852
+ .then(function readResponse(response) {
853
+ return response && response.ok === true ? Promise.resolve(response.json()) : null;
854
+ })
855
+ .then(function parsed(json) {
856
+ return json && typeof json === 'object' ? json : null;
857
+ })
858
+ .catch(function networkError() {
859
+ return null;
860
+ });
861
+ } catch (error) {
862
+ return Promise.resolve(null);
863
+ }
864
+ }
865
+
790
866
  /**
791
867
  * P0 boundary: read the current plan state. Keep this signature stable —
792
868
  * swapping the transport (HTTP route / projection / wire view) only changes
@@ -2055,6 +2131,184 @@ window.__ModuleLoader__.load({
2055
2131
  );
2056
2132
  }
2057
2133
 
2134
+ // =====================================================================
2135
+ // Settings page (settings.plugins.tab)
2136
+ // =====================================================================
2137
+ const SETTINGS_SLOT = 'settings.plugins.tab';
2138
+ const SETTINGS_ID = 'plan-completion-settings';
2139
+ const SETTINGS_ORDER = 60;
2140
+
2141
+ function settingsCss() {
2142
+ return [
2143
+ '.dpc-settings-panel{display:flex;flex-direction:column;gap:14px;padding:16px 18px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);max-width:560px}',
2144
+ '.dpc-settings-title{font-size:15px;font-weight:600;line-height:24px}',
2145
+ '.dpc-settings-group{display:flex;flex-direction:column;gap:8px}',
2146
+ '.dpc-settings-label{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
2147
+ '.dpc-settings-radio{display:flex;align-items:center;gap:8px;cursor:pointer}',
2148
+ '.dpc-settings-fixed{display:flex;gap:8px;padding:8px 0 0 24px;flex-wrap:wrap}',
2149
+ '.dpc-settings-input{flex:1 1 180px;min-width:140px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
2150
+ '.dpc-settings-check{display:flex;align-items:center;gap:8px;cursor:pointer}',
2151
+ '.dpc-settings-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
2152
+ '.dpc-settings-error{font-size:12px;color:var(--dsw-alias-state-error-primary,var(--dsw-alias-label-secondary))}',
2153
+ '.dpc-settings-save{align-self:flex-start;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:5px 14px;font-size:12px;line-height:18px;cursor:pointer;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
2154
+ '.dpc-settings-save:disabled{opacity:.5;cursor:default}',
2155
+ '.dpc-settings-status{padding:16px 18px;font-size:13px;color:var(--dsw-alias-label-secondary)}'
2156
+ ].join('\n');
2157
+ }
2158
+
2159
+ function PlanSettingsPage(props) {
2160
+ const t = makeT(props);
2161
+ const [settings, setSettings] = React.useState(null);
2162
+ const [status, setStatus] = React.useState('loading');
2163
+ const [saving, setSaving] = React.useState(false);
2164
+ const [error, setError] = React.useState('');
2165
+ const [saved, setSaved] = React.useState(false);
2166
+
2167
+ React.useEffect(function loadSettings() {
2168
+ let alive = true;
2169
+ fetchSettings().then(function applySettings(result) {
2170
+ if (!alive) return;
2171
+ if (!result || result.ok !== true || !result.settings) {
2172
+ setStatus('error');
2173
+ return;
2174
+ }
2175
+ setSettings(result.settings);
2176
+ setStatus('ready');
2177
+ });
2178
+ return function cleanup() {
2179
+ alive = false;
2180
+ };
2181
+ }, []);
2182
+
2183
+ function patch(next) {
2184
+ setSaved(false);
2185
+ setSettings(function keep(previous) {
2186
+ return previous ? { ...previous, ...next } : previous;
2187
+ });
2188
+ }
2189
+
2190
+ function save() {
2191
+ if (!settings || saving) return;
2192
+ setSaving(true);
2193
+ setError('');
2194
+ saveSettings({
2195
+ modelPolicy: settings.modelPolicy,
2196
+ fixedProvider: settings.fixedProvider || '',
2197
+ fixedModel: settings.fixedModel || '',
2198
+ modelCalls: settings.modelCalls === true
2199
+ }).then(function applied(result) {
2200
+ setSaving(false);
2201
+ if (!result || result.ok !== true) {
2202
+ const detail = result && typeof result.error === 'string' ? result.error : '';
2203
+ setError(detail ? t('settings.saveError') + ': ' + detail : t('settings.saveError'));
2204
+ return;
2205
+ }
2206
+ setSettings(result.settings || settings);
2207
+ setSaved(true);
2208
+ setError('');
2209
+ });
2210
+ }
2211
+
2212
+ const styleTag = h('style', { key: 'style', 'data-plan-completion-style': 'settings' }, settingsCss());
2213
+
2214
+ if (status === 'loading') {
2215
+ return h(React.Fragment, null, styleTag, h('div', { key: 'loading', className: 'dpc-settings-status' }, t('settings.loading')));
2216
+ }
2217
+ if (status === 'error' || !settings) {
2218
+ return h(
2219
+ React.Fragment,
2220
+ null,
2221
+ styleTag,
2222
+ h('div', { key: 'error', className: 'dpc-settings-status dpc-settings-error' }, t('settings.error'))
2223
+ );
2224
+ }
2225
+
2226
+ const policies = [
2227
+ { value: 'default', label: t('settings.policy.default') },
2228
+ { value: 'fixed', label: t('settings.policy.fixed') },
2229
+ { value: 'auto', label: t('settings.policy.auto') }
2230
+ ];
2231
+ const defaultRoute = settings.defaultSelection
2232
+ ? settings.defaultSelection.provider + '/' + settings.defaultSelection.model
2233
+ : '-';
2234
+
2235
+ return h(
2236
+ React.Fragment,
2237
+ null,
2238
+ styleTag,
2239
+ h(
2240
+ 'div',
2241
+ { key: 'panel', className: 'dpc-settings-panel' },
2242
+ h('div', { key: 'title', className: 'dpc-settings-title' }, t('settings.title')),
2243
+ h(
2244
+ 'div',
2245
+ { key: 'model', className: 'dpc-settings-group' },
2246
+ h('div', { key: 'label', className: 'dpc-settings-label' }, t('settings.model')),
2247
+ ...policies.map(function renderPolicy(option) {
2248
+ return h(
2249
+ 'label',
2250
+ { key: 'policy-' + option.value, className: 'dpc-settings-radio' },
2251
+ h('input', {
2252
+ type: 'radio',
2253
+ name: 'dpc-model-policy',
2254
+ value: option.value,
2255
+ checked: settings.modelPolicy === option.value,
2256
+ onChange: function onPolicy() {
2257
+ patch({ modelPolicy: option.value });
2258
+ }
2259
+ }),
2260
+ h('span', null, option.label)
2261
+ );
2262
+ }),
2263
+ h('div', { key: 'hint', className: 'dpc-settings-hint' }, t('settings.defaultRoute', { value: defaultRoute })),
2264
+ settings.modelPolicy === 'fixed'
2265
+ ? h(
2266
+ 'div',
2267
+ { key: 'fixed', className: 'dpc-settings-fixed' },
2268
+ h('input', {
2269
+ key: 'provider',
2270
+ className: 'dpc-settings-input',
2271
+ placeholder: t('settings.provider'),
2272
+ value: settings.fixedProvider || '',
2273
+ onChange: function onProvider(event) {
2274
+ patch({ fixedProvider: event.target.value });
2275
+ }
2276
+ }),
2277
+ h('input', {
2278
+ key: 'model',
2279
+ className: 'dpc-settings-input',
2280
+ placeholder: t('settings.modelId'),
2281
+ value: settings.fixedModel || '',
2282
+ onChange: function onModel(event) {
2283
+ patch({ fixedModel: event.target.value });
2284
+ }
2285
+ })
2286
+ )
2287
+ : null
2288
+ ),
2289
+ h(
2290
+ 'label',
2291
+ { key: 'calls', className: 'dpc-settings-check' },
2292
+ h('input', {
2293
+ type: 'checkbox',
2294
+ checked: settings.modelCalls === true,
2295
+ onChange: function onCalls(event) {
2296
+ patch({ modelCalls: event.target.checked });
2297
+ }
2298
+ }),
2299
+ h('span', null, t('settings.modelCalls'))
2300
+ ),
2301
+ error ? h('div', { key: 'error', className: 'dpc-settings-error' }, error) : null,
2302
+ saved && !error ? h('div', { key: 'saved', className: 'dpc-settings-hint' }, t('settings.saved')) : null,
2303
+ h(
2304
+ 'button',
2305
+ { key: 'save', type: 'button', className: 'dpc-settings-save', disabled: saving, onClick: save },
2306
+ saving ? t('settings.saving') : t('settings.save')
2307
+ )
2308
+ )
2309
+ );
2310
+ }
2311
+
2058
2312
  // =====================================================================
2059
2313
  // Plugin entry
2060
2314
  // =====================================================================
@@ -2090,6 +2344,13 @@ window.__ModuleLoader__.load({
2090
2344
  return ctx.slots.register({ name: OVERLAY_SLOT, id: OVERLAY_ID, order: OVERLAY_ORDER }, PlanPopover);
2091
2345
  });
2092
2346
 
2347
+ ctx.slots.inject(SETTINGS_SLOT, function registerSettingsPage() {
2348
+ return ctx.slots.register(
2349
+ { name: SETTINGS_SLOT, id: SETTINGS_ID, order: SETTINGS_ORDER, label: '计划补全' },
2350
+ PlanSettingsPage
2351
+ );
2352
+ });
2353
+
2093
2354
  ctx.effect(function installKeyboard() {
2094
2355
  if (typeof window === 'undefined') return undefined;
2095
2356
  const onKeyDown = function onKeyDown(event) {
package/cordis.patch.yml CHANGED
@@ -1,16 +1,16 @@
1
- - insert:
2
- - id: plan-completion
3
- name: 'dsh-plan-completion'
4
- config:
5
- evidenceChars: 1500
6
- maxTokensPatch: 400
7
- maxTokensRewrite: 1200
8
- warmBackfill: true
9
- modelCalls: true # false: heuristic-only; no plugin model requests
10
- # Plans + last suggestion are persisted per session so a DSH restart restores the UI.
11
- persistPlans: true
12
- storageDir: '~/.dsh/plan-completion-data'
13
- # Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
14
- enableDebugRoutes: false
15
- # Optional: set a local directory to persist debug logs / experiment data.
16
- # debugDir: '<local debug directory>'
1
+ - insert:
2
+ - id: plan-completion
3
+ name: 'dsh-plan-completion'
4
+ config:
5
+ evidenceChars: 1500
6
+ maxTokensPatch: 400
7
+ maxTokensRewrite: 1200
8
+ warmBackfill: true
9
+ modelCalls: true # false: heuristic-only; no plugin model requests
10
+ # Plans + last suggestion are persisted per session so a DSH restart restores the UI.
11
+ persistPlans: true
12
+ storageDir: '~/.dsh/plan-completion-data'
13
+ # Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
14
+ enableDebugRoutes: false
15
+ # Optional: set a local directory to persist debug logs / experiment data.
16
+ # debugDir: '<local debug directory>'
@@ -1,7 +1,11 @@
1
1
  # GitHub 发布评估 · 会话计划补全插件(2026-11-02)
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.3**(核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.5**(计划停更检测 + 建议文本保真 + 核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
  >
5
+ > v0.1.5 发布记录(2026-11-02):计划停更检测——连续 2 次 op=none 后建议变为 `/replan …` 并标记 stale。tag `v0.1.5` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.5);Actions [run 41391402601](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41391402601)(四 job 全绿,133/133 tests);npm `dsh-plan-completion@0.1.5`(shasum `c53c6d7ef79d8634913054db79fa9e5b1e86b182`)。
6
+ >
7
+ > v0.1.4 发布记录(2026-11-02):建议文本保真修复(标识符/路径/命令保留 + 语义化截断)。tag `v0.1.4` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.4);GitHub Actions [run 41389845733](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41389845733)(四 job 全绿,132/132 tests);npm `dsh-plan-completion@0.1.4`(31 files,shasum `a6f8953445e330081243f9f9d6b429fd4402b341`)。
8
+ >
5
9
  > v0.1.3 发布记录(2026-11-02):tag `v0.1.3` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.3);GitHub Actions [run 41368609382](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41368609382)(Ubuntu/Windows × Node20/22 四 job 全绿,126/126 tests);npm `dsh-plan-completion@0.1.3`(31 files,shasum `11adee0fe9acff199a180be511ca8bc600afdf6f`);canary 验证 `/plan-completion/verify` 未认证 401、客户端鉴权请求正常。
6
10
  >
7
11
  > v0.1.1 发布记录(2026-11-02):tag `v0.1.1` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.1);GitHub Actions [run 41366651557](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41366651557)(Ubuntu/Windows × Node20/22 四 job 全绿);npm `dsh-plan-completion@0.1.1`(31 files,shasum `18989b0f50b72d5c8c332f3fdee7f43ccb5fd91a`)。
@@ -105,3 +109,5 @@ git tag v0.1.0 && git push --tags
105
109
  > 建议:**先把 P0 B1~B8 全部打勾,再公开仓库**;否则许可证与数据问题会成为不可逆的历史提交。
106
110
 
107
111
 
112
+
113
+
package/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * The Client half only talks to this half through `/plan-completion/*`
12
12
  * (verified contract: same-origin fetch from the Electron page works and the body is readable).
13
13
  */
14
- import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
14
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
16
 
17
17
  import { DEFAULTS } from './host/config.js'
@@ -63,6 +63,38 @@ function expandStorageDir(value, envValue) {
63
63
  return raw
64
64
  }
65
65
 
66
+ const MODEL_POLICIES = new Set(['default', 'fixed', 'auto'])
67
+
68
+ function normalizeModelPolicy(value) {
69
+ const policy = typeof value === 'string' ? value.trim() : ''
70
+ return MODEL_POLICIES.has(policy) ? policy : 'default'
71
+ }
72
+
73
+ /** Settings written through the model-settings page (persisted next to the plan store). */
74
+ function readSavedSettings(dir) {
75
+ if (!dir) return {}
76
+ try {
77
+ const data = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8'))
78
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {}
79
+ } catch {
80
+ return {}
81
+ }
82
+ }
83
+
84
+ function writeSavedSettings(dir, data) {
85
+ if (!dir) return false
86
+ try {
87
+ mkdirSync(dir, { recursive: true })
88
+ const target = join(dir, 'settings.json')
89
+ const tmp = join(dir, `.settings-${process.pid}-${Date.now()}.json.tmp`)
90
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
91
+ renameSync(tmp, target)
92
+ return true
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
66
98
  function readJsonBody(req) {
67
99
  return new Promise(resolve => {
68
100
  let size = 0
@@ -154,11 +186,56 @@ function normalizePromptMessages(prompt) {
154
186
  /**
155
187
  * Use the user's current default route only. Never send session evidence to
156
188
  * another configured provider/model just because it may respond faster.
189
+ *
190
+ * v0.1.6 model policy:
191
+ * default — follow the host's current default model (previous behaviour)
192
+ * fixed — always use `fixedProvider`/`fixedModel` (only if resolvable)
193
+ * auto — within the default provider, prefer a model without reasoning efforts
194
+ * Without a host default route the generator falls back to heuristics (null).
157
195
  */
158
- async function pickModel(services) {
196
+ async function pickModel(services, policy = null) {
159
197
  const llm = services.llm
160
198
  const selection = services.agentDefaultModel?.currentSelection?.()
161
- if (!llm || !selection?.provider || !selection?.model) return null
199
+ if (!llm) return null
200
+ const mode = policy?.modelPolicy === 'fixed' ? 'fixed' : policy?.modelPolicy === 'auto' ? 'auto' : 'default'
201
+
202
+ if (mode === 'fixed' && policy?.fixedProvider && policy?.fixedModel) {
203
+ const route = { provider: policy.fixedProvider, model: policy.fixedModel, source: 'fixed' }
204
+ try {
205
+ const info = await llm.resolveModelInfo?.(route.provider, route.model)
206
+ if (info) {
207
+ const efforts = info?.reasoning?.efforts
208
+ if (Array.isArray(efforts) && efforts.length) route.reasoningEffort = efforts[0]?.id
209
+ return route
210
+ }
211
+ } catch {
212
+ /* fixed route unavailable: fall back to the host default below */
213
+ }
214
+ }
215
+
216
+ if (!selection?.provider || !selection?.model) return null
217
+
218
+ if (mode === 'auto') {
219
+ try {
220
+ const models = typeof llm.listModels === 'function' ? await llm.listModels(selection.provider) : []
221
+ for (const model of Array.isArray(models) ? models : []) {
222
+ const id = String(model?.id ?? '')
223
+ if (!id) continue
224
+ try {
225
+ const info = await llm.resolveModelInfo?.(selection.provider, id)
226
+ const efforts = info?.reasoning?.efforts
227
+ if (!Array.isArray(efforts) || efforts.length === 0) {
228
+ return { provider: selection.provider, model: id, source: 'auto' }
229
+ }
230
+ } catch {
231
+ /* try the next catalog entry */
232
+ }
233
+ }
234
+ } catch {
235
+ /* fall back to the host default */
236
+ }
237
+ }
238
+
162
239
  const route = { provider: selection.provider, model: selection.model, source: 'session-default' }
163
240
  try {
164
241
  const info = await llm.resolveModelInfo?.(route.provider, route.model)
@@ -215,7 +292,7 @@ const HOST_GLOBAL_KEY = '__dsh_plan_completion_host__'
215
292
  * up with two live handler sets answering different state.
216
293
  */
217
294
  function hostGlobal() {
218
- const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), disposers: [] }
295
+ const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), noneStreaks: new Map(), disposers: [] }
219
296
  if (typeof globalThis === 'undefined') return { ...fallback, store: createMemoryStore() }
220
297
  if (!globalThis[HOST_GLOBAL_KEY] || typeof globalThis[HOST_GLOBAL_KEY] !== 'object') {
221
298
  globalThis[HOST_GLOBAL_KEY] = fallback
@@ -225,6 +302,7 @@ function hostGlobal() {
225
302
  if (!(shared.sessions instanceof Map)) shared.sessions = new Map()
226
303
  if (!(shared.handledTurns instanceof Map)) shared.handledTurns = new Map()
227
304
  if (!(shared.warmAttempts instanceof Map)) shared.warmAttempts = new Map()
305
+ if (!(shared.noneStreaks instanceof Map)) shared.noneStreaks = new Map()
228
306
  if (!Array.isArray(shared.disposers)) shared.disposers = []
229
307
  if (!shared.store || typeof shared.store.get !== 'function' || typeof shared.store.put !== 'function') {
230
308
  shared.store = createMemoryStore()
@@ -246,14 +324,19 @@ export function apply(ctx, config = {}) {
246
324
  return Math.min(max, Math.max(min, number))
247
325
  }
248
326
  const rawConfig = config && typeof config === 'object' ? config : {}
327
+ const storageDir = expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR)
328
+ const savedSettings = readSavedSettings(storageDir)
249
329
  const settings = {
250
330
  evidenceChars: readInt(rawConfig.evidenceChars, 1500, 400, 8000),
251
331
  maxTokensPatch: readInt(rawConfig.maxTokensPatch, 400, 120, 4000),
252
332
  maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
253
333
  warmBackfill: rawConfig.warmBackfill !== false,
254
- modelCalls: rawConfig.modelCalls !== false,
334
+ modelCalls: (typeof savedSettings.modelCalls === 'boolean' ? savedSettings.modelCalls : true) && rawConfig.modelCalls !== false,
335
+ modelPolicy: normalizeModelPolicy(savedSettings.modelPolicy ?? rawConfig.modelPolicy),
336
+ fixedProvider: typeof savedSettings.fixedProvider === 'string' ? savedSettings.fixedProvider.trim().slice(0, 120) : '',
337
+ fixedModel: typeof savedSettings.fixedModel === 'string' ? savedSettings.fixedModel.trim().slice(0, 120) : '',
255
338
  persistPlans: rawConfig.persistPlans !== false,
256
- storageDir: expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR),
339
+ storageDir,
257
340
  maxTokensVerify: readInt(rawConfig.maxTokensVerify, DEFAULTS.maxTokensVerify, 200, 2000),
258
341
  enableDebugRoutes: rawConfig.enableDebugRoutes === true,
259
342
  debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
@@ -336,6 +419,28 @@ export function apply(ctx, config = {}) {
336
419
  }
337
420
  }
338
421
 
422
+ function settingsSnapshot() {
423
+ const selection = services.agentDefaultModel?.currentSelection?.() ?? null
424
+ let providers = []
425
+ try {
426
+ providers = services.llm?.listProviders?.() ?? []
427
+ } catch {
428
+ providers = []
429
+ }
430
+ return {
431
+ modelPolicy: settings.modelPolicy,
432
+ fixedProvider: settings.fixedProvider,
433
+ fixedModel: settings.fixedModel,
434
+ modelCalls: settings.modelCalls,
435
+ defaultSelection: selection && selection.provider && selection.model
436
+ ? { provider: selection.provider, model: selection.model }
437
+ : null,
438
+ providers: Array.isArray(providers)
439
+ ? providers.map(provider => ({ id: provider?.id ?? '', name: provider?.name ?? provider?.id ?? '' }))
440
+ : [],
441
+ }
442
+ }
443
+
339
444
  function publishState(sessionId, patch) {
340
445
  const previous = states.get(sessionId) ?? { plan: null, suggestion: null, stale: false }
341
446
  states.set(sessionId, { ...previous, ...patch })
@@ -632,7 +737,7 @@ export function apply(ctx, config = {}) {
632
737
  ]
633
738
  }
634
739
  try {
635
- const route = await pickModel(services)
740
+ const route = await pickModel(services, settings)
636
741
  if (!isCurrent()) return
637
742
  debug.model = route
638
743
  debugLog({ phase: 'model-choice', sessionId, route })
@@ -754,12 +859,22 @@ export function apply(ctx, config = {}) {
754
859
  const withinSuggestionDeadline = elapsed <= suggestionDeadline
755
860
  const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
756
861
 
862
+ // A long `op=none` streak means the visible plan stopped tracking the conversation
863
+ // (edited-by-user plans cannot be rewritten automatically, and one coarse step may need
864
+ // splitting). Surface an actionable replan suggestion instead of repeating a stale one.
865
+ const priorNoneStreak = noneStreaks.get(sessionId) ?? 0
866
+ const nextNoneStreak = force ? 0 : outcome === 'none' && nextPlan ? priorNoneStreak + 1 : 0
867
+ const replanSuggested = nextNoneStreak >= 2 && Boolean(nextPlan)
868
+ const publishedSuggestion = replanSuggested
869
+ ? { text: '/replan 按最近进展重排计划,把已完成项标记完成', alternates: [] }
870
+ : finalSuggestion
871
+
757
872
  if (nextPlan) {
758
873
  try {
759
874
  // Check CAS even for an unchanged plan; an old snapshot is never an
760
875
  // unconditional write. null also protects the first commit from races.
761
876
  // The last suggestion is persisted in the same write so a restart can restore it.
762
- const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: finalSuggestion })
877
+ const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: publishedSuggestion })
763
878
  if (!committed) {
764
879
  debugLog({ phase: 'store-cas-miss', sessionId })
765
880
  const winner = typeof store.getState === 'function'
@@ -779,13 +894,23 @@ export function apply(ctx, config = {}) {
779
894
  }
780
895
  }
781
896
 
897
+ noneStreaks.set(sessionId, nextNoneStreak)
782
898
  publishState(sessionId, {
783
899
  plan: nextPlan,
784
- suggestion: finalSuggestion,
785
- stale: false,
900
+ suggestion: publishedSuggestion,
901
+ stale: replanSuggested,
786
902
  })
787
- debug.lastJob = { sessionId, turn, elapsed, outcome, suggestion: Boolean(suggestion), withinSuggestionDeadline }
788
- debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline })
903
+ debug.lastJob = {
904
+ sessionId,
905
+ turn,
906
+ elapsed,
907
+ outcome,
908
+ suggestion: Boolean(suggestion),
909
+ withinSuggestionDeadline,
910
+ noneStreak: nextNoneStreak,
911
+ replanSuggested,
912
+ }
913
+ debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline, replanSuggested })
789
914
  } catch (error) {
790
915
  if (!isCurrent()) return
791
916
  debugLog({ phase: 'job-failed', sessionId, turn, error: String(error) })
@@ -800,6 +925,7 @@ export function apply(ctx, config = {}) {
800
925
 
801
926
  // ── Session event subscription: one plan job per completed turn ──────────
802
927
  const handledTurns = shared.handledTurns // sessionId -> last handled turn number
928
+ const noneStreaks = shared.noneStreaks // sessionId -> consecutive op=none outcomes
803
929
 
804
930
  function triggerTurnEnd(session, seq, turn, source) {
805
931
  if (!session?.id || disposed) return
@@ -987,7 +1113,7 @@ export function apply(ctx, config = {}) {
987
1113
  jsonResponse(res, 200, { ok: false, error: 'in-progress' })
988
1114
  return
989
1115
  }
990
- const route = await pickModel(services)
1116
+ const route = await pickModel(services, settings)
991
1117
  if (!route) {
992
1118
  jsonResponse(res, 200, { ok: false, error: 'no-model-route' })
993
1119
  return
@@ -1055,6 +1181,54 @@ export function apply(ctx, config = {}) {
1055
1181
  }
1056
1182
  return
1057
1183
  }
1184
+ if (pathname === `${ROUTE_PREFIX}settings` && method === 'GET') {
1185
+ jsonResponse(res, 200, { ok: true, settings: settingsSnapshot() })
1186
+ return
1187
+ }
1188
+ if (pathname === `${ROUTE_PREFIX}settings` && method === 'POST') {
1189
+ const body = (await readJsonBody(req)) ?? {}
1190
+ const next = { ...settingsSnapshot() }
1191
+ if (body.modelPolicy !== undefined) {
1192
+ const policy = normalizeModelPolicy(body.modelPolicy)
1193
+ if (policy !== String(body.modelPolicy).trim()) {
1194
+ jsonResponse(res, 200, { ok: false, error: 'bad-model-policy' })
1195
+ return
1196
+ }
1197
+ next.modelPolicy = policy
1198
+ }
1199
+ if (body.modelCalls !== undefined) {
1200
+ if (typeof body.modelCalls !== 'boolean') {
1201
+ jsonResponse(res, 200, { ok: false, error: 'bad-model-calls' })
1202
+ return
1203
+ }
1204
+ next.modelCalls = body.modelCalls
1205
+ }
1206
+ for (const key of ['fixedProvider', 'fixedModel']) {
1207
+ if (body[key] === undefined) continue
1208
+ if (typeof body[key] !== 'string') {
1209
+ jsonResponse(res, 200, { ok: false, error: 'bad-' + key })
1210
+ return
1211
+ }
1212
+ next[key] = body[key].trim().slice(0, 120)
1213
+ }
1214
+ if (next.modelPolicy === 'fixed' && (!next.fixedProvider || !next.fixedModel)) {
1215
+ jsonResponse(res, 200, { ok: false, error: 'missing-fixed-route' })
1216
+ return
1217
+ }
1218
+ settings.modelPolicy = next.modelPolicy
1219
+ settings.fixedProvider = next.fixedProvider
1220
+ settings.fixedModel = next.fixedModel
1221
+ settings.modelCalls = next.modelCalls
1222
+ const saved = writeSavedSettings(settings.storageDir, {
1223
+ modelPolicy: settings.modelPolicy,
1224
+ fixedProvider: settings.fixedProvider,
1225
+ fixedModel: settings.fixedModel,
1226
+ modelCalls: settings.modelCalls,
1227
+ })
1228
+ debugLog({ phase: 'settings-updated', modelPolicy: settings.modelPolicy, saved })
1229
+ jsonResponse(res, 200, { ok: true, saved, settings: settingsSnapshot() })
1230
+ return
1231
+ }
1058
1232
  if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
1059
1233
  const sessionId = url?.searchParams?.get('sessionId') ?? ''
1060
1234
  await hydrateSession(sessionId)
@@ -1161,6 +1335,7 @@ export function apply(ctx, config = {}) {
1161
1335
  states.delete(sessionId)
1162
1336
  warmAttempts.delete(sessionId)
1163
1337
  handledTurns.delete(sessionId)
1338
+ noneStreaks.delete(sessionId)
1164
1339
  debugLog({ phase: 'state-reset', sessionId, existed })
1165
1340
  jsonResponse(res, 200, { ok: true, sessionId, existed })
1166
1341
  return
@@ -1274,7 +1449,7 @@ export function apply(ctx, config = {}) {
1274
1449
  experimentState.error = null
1275
1450
  void (async () => {
1276
1451
  try {
1277
- const route = await pickModel(services)
1452
+ const route = await pickModel(services, settings)
1278
1453
  if (!route) throw new Error('no-model-route')
1279
1454
  const judgeRoute = await pickJudgeRoute(services, route)
1280
1455
  debugLog({ phase: 'experiment-start', sessionId, route, judgeRoute })
@@ -1367,6 +1542,7 @@ export function apply(ctx, config = {}) {
1367
1542
  `${ROUTE_PREFIX}replan`,
1368
1543
  `${ROUTE_PREFIX}warm`,
1369
1544
  `${ROUTE_PREFIX}verify`,
1545
+ `${ROUTE_PREFIX}settings`,
1370
1546
  ]
1371
1547
  if (settings.enableDebugRoutes) {
1372
1548
  paths.push(
@@ -1471,3 +1647,5 @@ export function apply(ctx, config = {}) {
1471
1647
  })
1472
1648
  }
1473
1649
 
1650
+
1651
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plan-completion",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",