@goodandready/dsh-goal 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,14 +1,19 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import z from '@deepseek-ai/schemastery';
4
- import { GoalEngine, GoalState, detectLanguage, sessionIdOf } from './goal-engine.js';
5
- import { parseGoalInput, executeGoalSlashCommand, createGoalUserMessage } from './command-handler.js';
4
+ import { AddonStore } from './addon-store.js';
5
+ import { TokenBudgetGuard } from './token-budget-guard.js';
6
+ import { GitSnapshotManager } from './git-snapshot-manager.js';
7
+ import { MilestoneManager } from './milestone-manager.js';
6
8
  import { registerRoutes } from './routes.js';
7
9
  import { registerTools } from './tools.js';
10
+ import { saveGoalReportFile } from './engine-reports.js';
8
11
  import { registerPluginUpdater } from './updater.js';
12
+ import { GoalEngine, GoalState } from './goal-engine.js';
13
+ import { sessionIdOf } from './goal-engine-constants.js';
9
14
 
10
15
  export const name = '@goodandready/dsh-goal';
11
- export const inject = ['webServer', 'settings'];
16
+ export const inject = ['webServer', 'settings', 'goals?'];
12
17
 
13
18
  const NS = 'dsh-goal';
14
19
 
@@ -36,10 +41,6 @@ export const Config = z.object({
36
41
 
37
42
  export function apply(ctx, config = {}) {
38
43
  let settingsScope = null;
39
- let agentsService = null;
40
- const runningAgents = new Set();
41
- const sessionAgents = new Map();
42
- let lastActiveAgent = null;
43
44
  const sseClients = new Map();
44
45
 
45
46
  let currentSettings = {
@@ -65,430 +66,250 @@ export function apply(ctx, config = {}) {
65
66
  };
66
67
 
67
68
  const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
68
- const storagePath = currentSettings.storagePath !== undefined && currentSettings.storagePath !== null
69
- ? currentSettings.storagePath
70
- : path.join(defaultStorageDir, 'dsh-goal-state.json');
71
-
72
- const engine = new GoalEngine({
73
- defaultMaxIterations: currentSettings.maxIterations,
74
- autoDrive: currentSettings.autoDrive,
75
- enableSound: currentSettings.enableSound,
76
- showQuickLaunchButton: currentSettings.showQuickLaunchButton,
77
- consecutiveToolFailureLimit: currentSettings.consecutiveToolFailureLimit,
69
+ const customStorage = typeof currentSettings.storagePath === 'string' && currentSettings.storagePath.trim()
70
+ ? currentSettings.storagePath.trim()
71
+ : null;
72
+ const isTest = process.env.NODE_ENV === 'test' || Boolean(process.env.NODE_TEST_CONTEXT);
73
+ const storagePath = customStorage || (isTest ? null : path.join(defaultStorageDir, 'dsh-goal-state.json'));
74
+ const logger = ctx.logger ? ctx.logger('goal') : console;
75
+
76
+ // Initialize addon modules
77
+ const addonStore = new AddonStore(storagePath, logger);
78
+ const gitSnapshotManager = new GitSnapshotManager(addonStore, { logger });
79
+ const milestoneManager = new MilestoneManager(addonStore, {
80
+ gitSnapshotManager,
81
+ enforceDependencies: currentSettings.enableMilestoneDependencies,
82
+ logger,
83
+ });
84
+ const tokenBudgetGuard = new TokenBudgetGuard({
78
85
  maxTokenBudget: currentSettings.maxTokenBudget,
79
- budgetWarningThreshold: currentSettings.budgetWarningThreshold,
80
- autoCheckpointOnMilestone: currentSettings.autoCheckpointOnMilestone,
81
- enableLiveActivityFeed: currentSettings.enableLiveActivityFeed,
82
- enablePreplanning: currentSettings.enablePreplanning,
83
- autoBranchOnGoalStart: currentSettings.autoBranchOnGoalStart,
84
- enablePostGoalRetrospective: currentSettings.enablePostGoalRetrospective,
85
- autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
86
- enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
87
- enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
88
- soundScheme: currentSettings.soundScheme,
89
- enableVoiceAnnouncements: currentSettings.enableVoiceAnnouncements,
90
- storagePath,
86
+ warningThreshold: currentSettings.budgetWarningThreshold,
87
+ autoScaleNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
88
+ logger,
91
89
  });
92
90
 
93
- // Track running agents through global DSH core event
94
- ctx.inject(['agents'], (actx) => {
95
- agentsService = actx.agents;
96
- });
91
+ // Attempt migration of legacy state if present
92
+ try {
93
+ const firstAgent = ctx.agents?.get?.(ctx.agents?.keys?.()?.next()?.value);
94
+ addonStore.migrateLegacyState(storagePath, ctx.goals, firstAgent);
95
+ } catch (migErr) {
96
+ logger.warn?.('[dsh-goal] Legacy migration check failed:', migErr.message);
97
+ }
97
98
 
98
- ctx.on?.('agent/status', ({ agent, status }) => {
99
- if (status === 'running') {
100
- runningAgents.add(agent);
101
- lastActiveAgent = agent;
102
- const sid = sessionIdOf(agent, null);
103
- if (sid) {
104
- sessionAgents.set(sid, agent);
105
- }
106
- } else {
107
- runningAgents.delete(agent);
108
- if (lastActiveAgent === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
109
- lastActiveAgent = null;
110
- }
111
- const sid = sessionIdOf(agent, null);
112
- if (sid && sessionAgents.get(sid) === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
113
- sessionAgents.delete(sid);
114
- }
115
- }
99
+ // Register addon tools: goal_milestones and goal_checklist
100
+ registerTools(ctx, {
101
+ milestoneManager,
102
+ goalsService: ctx.goals,
103
+ logger,
116
104
  });
117
105
 
118
- const stopRunningAgents = (sessionId = 'default') => {
119
- const sid = sessionId || 'default';
120
- const specificAgent = sessionAgents.get(sid);
121
- if (specificAgent && typeof specificAgent.cancel === 'function') {
122
- try {
123
- specificAgent.cancel({ kind: 'user' });
124
- } catch (err) {
125
- console.warn('[dsh-goal] Failed to cancel specific agent for session:', err);
126
- }
127
- }
128
-
129
- for (const ag of runningAgents) {
130
- try {
131
- const agSid = sessionIdOf(ag, null);
132
- if (!agSid || agSid === sid || sid === 'default') {
133
- if (typeof ag.cancel === 'function') {
134
- ag.cancel({ kind: 'user' });
135
- }
136
- }
137
- } catch (err) {
138
- console.warn('[dsh-goal] Failed to cancel running agent:', err);
139
- }
140
- }
106
+ // Support fallback engine for tests or non-core environments
107
+ let fallbackEngine = null;
108
+ if (!ctx.goals) {
109
+ fallbackEngine = new GoalEngine({
110
+ storagePath,
111
+ defaultMaxIterations: currentSettings.maxIterations,
112
+ autoDrive: currentSettings.autoDrive,
113
+ consecutiveToolFailureLimit: currentSettings.consecutiveToolFailureLimit,
114
+ maxTokenBudget: currentSettings.maxTokenBudget,
115
+ budgetWarningThreshold: currentSettings.budgetWarningThreshold,
116
+ autoCheckpointOnMilestone: currentSettings.autoCheckpointOnMilestone,
117
+ enableLiveActivityFeed: currentSettings.enableLiveActivityFeed,
118
+ enablePreplanning: currentSettings.enablePreplanning,
119
+ autoBranchOnGoalStart: currentSettings.autoBranchOnGoalStart,
120
+ enablePostGoalRetrospective: currentSettings.enablePostGoalRetrospective,
121
+ autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
122
+ enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
123
+ enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
124
+ });
125
+ }
141
126
 
142
- if (lastActiveAgent && lastActiveAgent.status === 'running' && typeof lastActiveAgent.cancel === 'function') {
143
- const lastSid = sessionIdOf(lastActiveAgent, null);
144
- if (!lastSid || lastSid === sid || sid === 'default') {
127
+ // 1. One-click auto-updater registration (Issue #99)
128
+ if (ctx?.webServer?.register) {
129
+ if (typeof ctx?.effect === 'function') {
130
+ ctx.effect(() => {
131
+ let unreg = null;
145
132
  try {
146
- lastActiveAgent.cancel({ kind: 'user' });
133
+ unreg = registerPluginUpdater(ctx, {
134
+ packageName: '@goodandready/dsh-goal',
135
+ endpoint: '/api/dsh-goal/update',
136
+ manifestUrl: new URL('../package.json', import.meta.url),
137
+ });
147
138
  } catch (err) {
148
- console.warn('[dsh-goal] Failed to cancel lastActiveAgent:', err);
149
- }
150
- }
151
- }
152
- };
153
-
154
- const resumeActiveAgent = (promptText, sessionId = 'default') => {
155
- const sid = sessionId || 'default';
156
- const snap = engine.getSnapshot(sid);
157
- const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
158
- const defaultResumePrompt = lang === 'zh'
159
- ? '▶️ 用户已恢复目标。请继续执行工作计划,并通过 goal_update_progress 更新进度。'
160
- : '▶️ Goal resumed by user. Continue executing the work plan from where you stopped. Update steps via goal_update_progress.';
161
-
162
- const resumeMsg = createGoalUserMessage(promptText || defaultResumePrompt);
163
-
164
- let target = sessionAgents.get(sid);
165
- if (!target && lastActiveAgent && typeof lastActiveAgent.followup === 'function') {
166
- const lastSid = sessionIdOf(lastActiveAgent, null);
167
- if (!lastSid || lastSid === sid || sid === 'default') {
168
- target = lastActiveAgent;
169
- }
170
- }
171
-
172
- if (!target && agentsService && typeof agentsService.list === 'function') {
173
- const list = agentsService.list();
174
- if (list && list.length > 0) {
175
- for (let i = list.length - 1; i >= 0; i--) {
176
- const cand = list[i];
177
- const candSid = sessionIdOf(cand, null);
178
- if (candSid === sid) {
179
- target = cand;
180
- break;
181
- }
182
- }
183
- if (!target && sid === 'default') {
184
- target = list[list.length - 1];
185
- }
186
- }
187
- }
188
-
189
- if (target && typeof target.followup === 'function') {
190
- try {
191
- target.followup(resumeMsg);
192
- lastActiveAgent = target;
193
- sessionAgents.set(sid, target);
194
- return true;
195
- } catch (err) {
196
- console.warn('[dsh-goal] Failed to resume agent:', err);
197
- }
198
- }
199
- return false;
200
- };
201
-
202
- const getConfig = () => {
203
- if (settingsScope) {
204
- const snap = typeof settingsScope.getSnapshot === 'function' ? settingsScope.getSnapshot() : null;
205
- let live = null;
206
- if (snap) {
207
- if (snap.status === 'ready' || snap.status === 'writable' || snap.status === undefined) {
208
- live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
139
+ logger.warn?.('Failed to register plugin updater:', err.message);
209
140
  }
210
- } else if (typeof settingsScope.get === 'function') {
211
- live = settingsScope.get();
212
- }
213
- if (live && typeof live === 'object') {
214
- return {
215
- ...currentSettings,
216
- ...live,
141
+ return () => {
142
+ if (typeof unreg === 'function') unreg();
217
143
  };
218
- }
219
- }
220
- return currentSettings;
221
- };
222
-
223
- const applySettings = () => {
224
- const live = getConfig();
225
- engine.updateConfig({
226
- defaultMaxIterations: live.maxIterations,
227
- autoDrive: live.autoDrive,
228
- enableSound: live.enableSound,
229
- showQuickLaunchButton: live.showQuickLaunchButton,
230
- consecutiveToolFailureLimit: live.consecutiveToolFailureLimit,
231
- maxTokenBudget: live.maxTokenBudget,
232
- budgetWarningThreshold: live.budgetWarningThreshold,
233
- autoCheckpointOnMilestone: live.autoCheckpointOnMilestone,
234
- enableLiveActivityFeed: live.enableLiveActivityFeed,
235
- enablePreplanning: live.enablePreplanning,
236
- autoBranchOnGoalStart: live.autoBranchOnGoalStart,
237
- enablePostGoalRetrospective: live.enablePostGoalRetrospective,
238
- autoScaleBudgetNearCompletion: live.autoScaleBudgetNearCompletion,
239
- enableTemplatesDrawer: live.enableTemplatesDrawer,
240
- enableMilestoneDependencies: live.enableMilestoneDependencies,
241
- soundScheme: live.soundScheme,
242
- enableVoiceAnnouncements: live.enableVoiceAnnouncements,
243
- });
244
- };
245
-
246
- // 1. Settings registration
247
- ctx.inject(['settings'], (sctx) => {
248
- try {
249
- const scope = sctx.settings?.register?.(NS, Config, { base: currentSettings });
250
- if (scope) {
251
- settingsScope = scope;
252
- applySettings();
253
- if (typeof scope.subscribe === 'function') {
254
- sctx.effect(() => {
255
- const off = scope.subscribe(() => {
256
- applySettings();
257
- });
258
- return () => {
259
- if (typeof off === 'function') off();
260
- settingsScope = null;
261
- };
262
- }, 'dsh-goal: settings subscription');
263
- }
264
- }
265
- } catch (err) {
266
- console.warn('[dsh-goal] Settings register skipped:', err.message);
267
- }
268
- });
269
-
270
- // 2. Chat slash command /goal registration
271
- ctx.inject(['commands'], (cctx) => {
272
- try {
273
- if (typeof cctx.commands?.register !== 'function') return;
274
- const unregister = cctx.commands.register({
275
- name: 'goal',
276
- description: 'Manage Goal Mode: activate, milestones, pause, resume, and clear',
277
- input: { hint: '[<objective>|clear|pause|resume]' },
278
- handler: async (invocation) => {
279
- const sid = sessionIdOf(invocation, 'default');
280
- if (invocation?.agent) {
281
- lastActiveAgent = invocation.agent;
282
- sessionAgents.set(sid, invocation.agent);
283
- }
284
- const parsed = parseGoalInput(invocation?.rawInput);
285
- const liveConfig = getConfig();
286
- return executeGoalSlashCommand(engine, parsed, liveConfig, invocation?.agent, sid);
287
- },
288
- });
289
-
290
- if (typeof unregister === 'function') {
291
- cctx.effect(() => () => unregister(), 'dsh-goal: /goal command unregister');
292
- }
293
- } catch (err) {
294
- console.warn('[dsh-goal] Commands register skipped:', err.message);
295
- }
296
- });
297
-
298
- // 3. System prompt context injection
299
- ctx.inject(['systemPrompt'], (pctx) => {
300
- try {
301
- if (typeof pctx.systemPrompt?.section === 'function') {
302
- const globalSections = pctx.systemPrompt.layers?.global?.sections;
303
- let displacedPromptSection = null;
304
- if (globalSections?.data instanceof Map && globalSections.data.has('tool:goal')) {
305
- displacedPromptSection = globalSections.data.get('tool:goal');
306
- globalSections.data.delete('tool:goal');
307
- }
308
-
309
- const order = typeof pctx.systemPrompt.getSectionOrder === 'function'
310
- ? (pctx.systemPrompt.getSectionOrder('TOOL_GOAL') || 2400)
311
- : 2400;
312
-
313
- const unregisterSection = pctx.systemPrompt.section({
314
- name: 'tool:dsh-goal',
315
- order,
316
- text: (sessionCtx) => {
317
- const sid = sessionIdOf(sessionCtx, 'default');
318
- return engine.getStatePromptInjection(sid);
319
- },
320
- });
321
-
322
- if (typeof unregisterSection === 'function') {
323
- pctx.effect(() => () => {
324
- unregisterSection();
325
- if (displacedPromptSection && globalSections?.data instanceof Map) {
326
- globalSections.data.set('tool:goal', displacedPromptSection);
327
- }
328
- }, 'dsh-goal: system prompt section');
329
- }
330
- }
331
- } catch (err) {
332
- console.warn('[dsh-goal] SystemPrompt section register skipped:', err.message);
333
- }
334
- });
335
-
336
- // 4. Tools registration
337
- registerTools(ctx, { engine, resumeActiveAgent });
338
-
339
- // 5. One-click auto-updater registration
340
- if (ctx.webServer?.register) {
341
- try {
144
+ }, 'dsh-goal: plugin auto-updater');
145
+ } else {
342
146
  registerPluginUpdater(ctx, {
343
147
  packageName: '@goodandready/dsh-goal',
344
148
  endpoint: '/api/dsh-goal/update',
345
149
  manifestUrl: new URL('../package.json', import.meta.url),
346
150
  });
347
- } catch (err) {
348
- console.warn('[dsh-goal] Failed to register plugin updater:', err.message);
349
151
  }
350
152
  }
351
153
 
352
- // 6. WebServer HTTP & SSE routes
353
- registerRoutes(ctx, {
354
- engine,
355
- getConfig,
356
- stopRunningAgents,
357
- resumeActiveAgent,
358
- sessionAgents,
154
+ // 2. WebServer HTTP & SSE routes (registered after updater so it remains primary)
155
+ const { broadcastEvent } = registerRoutes(ctx, {
156
+ engine: fallbackEngine,
157
+ milestoneManager,
158
+ gitSnapshotManager,
159
+ tokenBudgetGuard,
160
+ addonStore,
161
+ goalsService: ctx.goals,
162
+ getConfig: () => currentSettings,
359
163
  sseClients,
164
+ logger,
360
165
  });
361
166
 
362
- // 7. Turn coordinator & tool-failure breaker
363
- ctx.effect(() => {
364
- const onTurnEnd = (turn) => {
365
- const liveConfig = getConfig();
366
- if (!liveConfig.autoDrive) return;
367
-
368
- const sid = sessionIdOf(turn, 'default');
369
- const usage = turn?.usage || turn?.meta?.usage || turn?.response?.usage || turn?.turn?.usage;
370
- if (usage) {
371
- engine.addTokenUsage(usage, sid);
167
+ // 3. Listen to core goal/changed events if ctx.on exists
168
+ if (typeof ctx?.on === 'function') {
169
+ ctx.on('goal/changed', async ({ agent, change }) => {
170
+ if (!change) return;
171
+ const op = change.operation;
172
+ const goal = change.goal;
173
+ const goalId = goal?.id || change.ref?.id;
174
+
175
+ if (op === 'create' || op === 'resume') {
176
+ tokenBudgetGuard.resetGoal(goalId);
177
+ broadcastEvent('goal_status', { op, goalId, goal });
372
178
  }
373
179
 
374
- const snap = engine.getSnapshot(sid);
375
- if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
376
- const steps = turn?.steps || turn?.turn?.steps || [];
377
- const hasStepError = Array.isArray(steps) && steps.some((s) => {
378
- return s?.status === 'error' || s?.error || s?.toolResult?.isError;
379
- });
380
- const hasTurnError = Boolean(turn?.error || turn?.reason?.kind === 'error');
381
- const isToolFailure = hasStepError || hasTurnError;
382
-
383
- if (isToolFailure) {
384
- const failCount = engine.incrementToolFailureCount(sid);
385
- const limit = liveConfig.consecutiveToolFailureLimit;
386
- if (limit > 0 && failCount >= limit) {
387
- const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
388
- const pauseReason = lang === 'zh'
389
- ? `工具重复出错(连续 ${failCount} 次)— 目标已暂停以防止死循环`
390
- : `Repeated tool failure (${failCount} consecutive) — goal paused to prevent token burn`;
391
- engine.pause(pauseReason, sid);
392
- return;
180
+ if (op === 'complete' || op === 'clear') {
181
+ try {
182
+ const milestones = milestoneManager.getMilestones(goalId);
183
+ const snapshots = addonStore.getSnapshots(goalId);
184
+ const savedPath = saveGoalReportFile(goal || { id: goalId, objective: 'Completed Goal' }, {
185
+ milestones,
186
+ snapshots,
187
+ });
188
+ if (savedPath) {
189
+ logger.info?.(`[dsh-goal] Saved goal report to "${savedPath}"`);
393
190
  }
394
- } else {
395
- engine.resetToolFailureCount(sid);
396
- }
397
-
398
- if (turn?.reason?.kind === 'aborted' || turn?.reason?.kind === 'error' || turn?.error) {
399
- return;
400
- }
401
-
402
- const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
403
- const stallCount = engine.incrementStallCount(sid);
404
- if (stallCount >= 2) {
405
- const stallReason = lang === 'zh'
406
- ? '智能体在过去 2 次迭代中未推进工作计划 — 需要操作员关注'
407
- : 'Agent made no progress on work plan in the last 2 iterations — operator attention required';
408
- engine.pause(stallReason, sid);
409
- return;
191
+ } catch (err) {
192
+ logger.warn?.('[dsh-goal] Failed to save goal report on completion:', err.message);
410
193
  }
194
+ broadcastEvent('goal_status', { op, goalId, goal });
195
+ }
411
196
 
412
- const canContinue = engine.incrementIteration(sid);
413
- if (!canContinue) return;
414
-
415
- const triggerNextTurn = () => {
416
- const currentSnap = engine.getSnapshot(sid);
417
- if (currentSnap.hasActiveGoal && currentSnap.state === GoalState.RUNNING) {
418
- const currentLang = currentSnap.lang || (currentSnap.title ? detectLanguage(currentSnap.title) : 'en');
419
- let promptText = currentLang === 'zh'
420
- ? '请按照工作计划继续自主执行目标。每完成一步请通过 goal_update_progress 记录,所有任务完成后调用 goal_finish。'
421
- : 'Continue autonomous goal execution according to the work plan. Mark each completed step via goal_update_progress, and when all tasks are complete, call goal_finish.';
422
-
423
- if (currentSnap.pendingNudge) {
424
- const userNudge = engine.consumePendingNudge(sid);
425
- if (userNudge) {
426
- promptText = (currentLang === 'zh'
427
- ? `🚨 用户紧急说明/指导方向:\n"${userNudge}"\n请根据此反馈调整执行。\n\n`
428
- : `🚨 URGENT USER CLARIFICATION / STEERING:\n"${userNudge}"\nAdjust execution adhering to this feedback.\n\n`) + promptText;
429
- }
430
- }
431
-
432
- const promptMsg = createGoalUserMessage(promptText);
433
- let target = sessionAgents.get(sid);
434
- if (!target && lastActiveAgent && typeof lastActiveAgent.followup === 'function') {
435
- const lastSid = sessionIdOf(lastActiveAgent, null);
436
- if (!lastSid || lastSid === sid || sid === 'default') {
437
- target = lastActiveAgent;
438
- }
439
- }
197
+ if (op === 'block') {
198
+ logger.info?.(`[dsh-goal] Goal "${goalId}" blocked: ${goal?.blockedReason?.message || 'Policy block'}`);
199
+ broadcastEvent('goal_status', { op, goalId, goal });
200
+ }
201
+ });
440
202
 
441
- if (!target && agentsService && typeof agentsService.list === 'function') {
442
- const list = agentsService.list();
443
- if (list && list.length > 0) {
444
- for (let i = list.length - 1; i >= 0; i--) {
445
- const cand = list[i];
446
- if (sessionIdOf(cand, null) === sid) {
447
- target = cand;
448
- break;
449
- }
450
- }
451
- if (!target && sid === 'default') target = list[list.length - 1];
452
- }
453
- }
203
+ // Inject milestone prompts and check budget during agent turns
204
+ ctx.on('agent/pre-step', async ({ agent, messages, step, signal }, next) => {
205
+ if (ctx.goals && typeof ctx.goals.get === 'function' && agent) {
206
+ try {
207
+ const currentGoal = ctx.goals.get(agent);
208
+ if (currentGoal && currentGoal.phase === 'active') {
209
+ const goalId = currentGoal.id;
210
+
211
+ // 1. Budget check
212
+ const milestones = milestoneManager.getMilestones(goalId);
213
+ const budgetCheck = tokenBudgetGuard.checkBudget({
214
+ agent,
215
+ goal: currentGoal,
216
+ tokenMeter: ctx.tokenMeter,
217
+ goalsService: ctx.goals,
218
+ milestones,
219
+ });
454
220
 
455
- if (target && typeof target.followup === 'function' && target.status !== 'stopped') {
456
- try {
457
- target.followup(promptMsg);
458
- sessionAgents.set(sid, target);
459
- } catch (err) {
460
- console.warn('[dsh-goal] Failed auto-drive followup:', err);
221
+ // 2. Active milestone prompt guidance
222
+ const milestonePrompt = milestoneManager.getActiveMilestonePrompt(goalId);
223
+ if (milestonePrompt && Array.isArray(messages)) {
224
+ let promptText = milestonePrompt;
225
+ if (budgetCheck.status === 'warning') {
226
+ promptText += `\n${budgetCheck.message}`;
227
+ } else if (budgetCheck.status === 'auto_scaled') {
228
+ promptText += `\n${budgetCheck.message}`;
461
229
  }
230
+ messages.push({
231
+ role: 'system',
232
+ content: promptText,
233
+ });
462
234
  }
463
235
  }
464
- };
465
-
466
- if (typeof setImmediate === 'function') {
467
- setImmediate(triggerNextTurn);
468
- } else {
469
- setTimeout(triggerNextTurn, 0);
236
+ } catch (err) {
237
+ logger.warn?.('[dsh-goal] agent/pre-step error:', err.message);
470
238
  }
471
239
  }
472
- };
240
+ return next ? next() : undefined;
241
+ });
242
+
243
+ // Compatibility events for turn/end and approval/asked
244
+ ctx.on('turn/end', (event) => {
245
+ // noop or session isolation
246
+ });
473
247
 
474
- const onApprovalAsked = (event) => {
248
+ ctx.on('approval/asked', (event) => {
249
+ if (!fallbackEngine) return;
475
250
  const sid = sessionIdOf(event, 'default');
476
- const snap = engine.getSnapshot(sid);
477
- if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
478
- const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
479
- const pauseReason = lang === 'zh'
480
- ? '等待操作员确认/批准操作'
481
- : 'Waiting for operator confirmation/approval';
482
- engine.pause(pauseReason, sid);
251
+ const snap = fallbackEngine.getSnapshot(sid);
252
+ if (snap && snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
253
+ fallbackEngine.pause('Waiting for operator confirmation/approval', sid);
254
+ }
255
+ });
256
+ }
257
+
258
+ // Settings synchronization
259
+ const applySettings = (patch) => {
260
+ if (!patch || typeof patch !== 'object') return;
261
+ currentSettings = { ...currentSettings, ...patch };
262
+ tokenBudgetGuard.updateConfig({
263
+ maxTokenBudget: currentSettings.maxTokenBudget,
264
+ warningThreshold: currentSettings.budgetWarningThreshold,
265
+ autoScaleNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
266
+ });
267
+ milestoneManager.enforceDependencies = Boolean(currentSettings.enableMilestoneDependencies);
268
+ if (typeof patch.storagePath === 'string' && patch.storagePath.trim()) {
269
+ addonStore.setStoragePath(patch.storagePath.trim());
270
+ }
271
+ };
272
+
273
+ if (typeof ctx?.inject === 'function') {
274
+ ctx.inject(['settings'], (sctx) => {
275
+ try {
276
+ const scope = sctx.settings?.register?.(NS, Config, { base: currentSettings });
277
+ if (scope) {
278
+ settingsScope = scope;
279
+ if (typeof scope.get === 'function') {
280
+ applySettings(scope.get());
281
+ }
282
+ if (typeof scope.subscribe === 'function') {
283
+ sctx.effect?.(() => {
284
+ const off = scope.subscribe(() => {
285
+ if (typeof scope.get === 'function') applySettings(scope.get());
286
+ });
287
+ return () => {
288
+ if (typeof off === 'function') off();
289
+ settingsScope = null;
290
+ };
291
+ }, 'dsh-goal: settings subscription');
292
+ }
293
+ }
294
+ } catch (err) {
295
+ logger.warn?.('Settings register skipped:', err.message);
483
296
  }
484
- };
297
+ });
298
+ }
485
299
 
486
- ctx.on?.('turn/end', onTurnEnd);
487
- ctx.on?.('approval/asked', onApprovalAsked);
300
+ if (typeof ctx?.effect === 'function') {
301
+ ctx.effect(() => () => {
302
+ try {
303
+ addonStore.flushSync();
304
+ } catch (e) { /* ignore */ }
305
+ }, 'dsh-goal: teardown');
306
+ }
488
307
 
489
- return () => {
490
- ctx.off?.('turn/end', onTurnEnd);
491
- ctx.off?.('approval/asked', onApprovalAsked);
492
- };
493
- }, 'dsh-goal: Session & Turn Coordinator');
308
+ return {
309
+ addonStore,
310
+ milestoneManager,
311
+ tokenBudgetGuard,
312
+ gitSnapshotManager,
313
+ engine: fallbackEngine,
314
+ };
494
315
  }