@goodandready/dsh-goal 0.1.0 → 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/lib/index.js CHANGED
@@ -1,34 +1,243 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import z from '@deepseek-ai/schemastery';
1
4
  import { GoalEngine, GoalState, MilestoneStatus } from './goal-engine.js';
5
+ import { parseGoalInput, executeGoalSlashCommand, createGoalUserMessage } from './command-handler.js';
2
6
 
3
7
  export const name = '@goodandready/dsh-goal';
4
8
  export const inject = ['webServer', 'settings'];
5
9
 
6
10
  const NS = 'dsh-goal';
7
11
 
12
+ // Схема-функция: в rc.1 settings.register(NS, schema, { base }) ожидает
13
+ // schemastery-схему вторым аргументом.
14
+ // Issue #24: storagePath объявлен в схеме конфигурации плагина
15
+ export const Config = z.object({
16
+ maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
17
+ autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
18
+ enableSound: z.boolean().default(true).description('Play a sound when a goal completes'),
19
+ storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
20
+ });
21
+
8
22
  export function apply(ctx, config = {}) {
9
- const engine = new GoalEngine({
10
- defaultMaxIterations: config?.maxIterations ?? 25,
11
- });
23
+ let settingsScope = null;
24
+ let agentsService = null;
25
+ const runningAgents = new Set();
26
+ let lastActiveAgent = null;
12
27
 
13
28
  let currentSettings = {
14
29
  maxIterations: config?.maxIterations ?? 25,
15
30
  autoDrive: config?.autoDrive ?? true,
16
31
  enableSound: config?.enableSound ?? true,
32
+ storagePath: config?.storagePath,
33
+ };
34
+
35
+ const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
36
+ const storagePath = currentSettings.storagePath !== undefined && currentSettings.storagePath !== null
37
+ ? currentSettings.storagePath
38
+ : path.join(defaultStorageDir, 'dsh-goal-state.json');
39
+
40
+ const engine = new GoalEngine({
41
+ defaultMaxIterations: currentSettings.maxIterations,
42
+ autoDrive: currentSettings.autoDrive,
43
+ enableSound: currentSettings.enableSound,
44
+ storagePath,
45
+ });
46
+
47
+ // Динамическое внедрение сервиса agents для управления жизненным циклом
48
+ ctx.inject(['agents'], (actx) => {
49
+ agentsService = actx.agents;
50
+ });
51
+
52
+ // Отслеживание выполняющихся агентов через глобальное событие ядра DSH
53
+ ctx.on?.('agent/status', ({ agent, status }) => {
54
+ if (status === 'running') {
55
+ runningAgents.add(agent);
56
+ lastActiveAgent = agent;
57
+ } else {
58
+ runningAgents.delete(agent);
59
+ }
60
+ });
61
+
62
+ const stopRunningAgents = () => {
63
+ // 1. Отмена всех агентов, находящихся в активном выполнении
64
+ for (const ag of runningAgents) {
65
+ try {
66
+ if (typeof ag.cancel === 'function') {
67
+ ag.cancel({ kind: 'user' });
68
+ }
69
+ } catch (err) {
70
+ console.warn('[dsh-goal] Failed to cancel running agent:', err);
71
+ }
72
+ }
73
+ runningAgents.clear();
74
+
75
+ // 2. Отмена последнего известного агента
76
+ if (lastActiveAgent && lastActiveAgent.status === 'running' && typeof lastActiveAgent.cancel === 'function') {
77
+ try {
78
+ lastActiveAgent.cancel({ kind: 'user' });
79
+ } catch (err) {
80
+ console.warn('[dsh-goal] Failed to cancel lastActiveAgent:', err);
81
+ }
82
+ }
83
+
84
+ // 3. Дополнительная проверка через agentsService.list()
85
+ if (agentsService && typeof agentsService.list === 'function') {
86
+ try {
87
+ for (const ag of agentsService.list()) {
88
+ if (ag && ag.status === 'running' && typeof ag.cancel === 'function') {
89
+ try {
90
+ ag.cancel({ kind: 'user' });
91
+ } catch (_) {}
92
+ }
93
+ }
94
+ } catch (err) {
95
+ console.warn('[dsh-goal] Failed to cancel agents from list:', err);
96
+ }
97
+ }
98
+ };
99
+
100
+ const resumeActiveAgent = (promptText) => {
101
+ const resumeMsg = createGoalUserMessage(
102
+ promptText || '▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.',
103
+ );
104
+ let target = null;
105
+ if (lastActiveAgent && typeof lastActiveAgent.followup === 'function') {
106
+ target = lastActiveAgent;
107
+ } else if (agentsService && typeof agentsService.list === 'function') {
108
+ const list = agentsService.list();
109
+ if (list && list.length > 0) {
110
+ target = list[list.length - 1];
111
+ }
112
+ }
113
+
114
+ if (target && typeof target.followup === 'function') {
115
+ try {
116
+ target.followup(resumeMsg);
117
+ lastActiveAgent = target;
118
+ return true;
119
+ } catch (err) {
120
+ console.warn('[dsh-goal] Failed to resume agent:', err);
121
+ }
122
+ }
123
+ return false;
124
+ };
125
+
126
+ // Issue #21: getConfig reads snap.value ONLY when status is 'ready' (or status is undefined in unit test mocks)
127
+ const getConfig = () => {
128
+ if (settingsScope) {
129
+ const snap = typeof settingsScope.getSnapshot === 'function' ? settingsScope.getSnapshot() : null;
130
+ if (snap) {
131
+ if (snap.status === 'ready' || snap.status === undefined) {
132
+ const live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
133
+ if (live && typeof live === 'object') {
134
+ return {
135
+ maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
136
+ autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
137
+ enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
138
+ storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
139
+ };
140
+ }
141
+ }
142
+ } else if (typeof settingsScope.get === 'function') {
143
+ const live = settingsScope.get();
144
+ if (live && typeof live === 'object') {
145
+ return {
146
+ maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
147
+ autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
148
+ enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
149
+ storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
150
+ };
151
+ }
152
+ }
153
+ }
154
+ return currentSettings;
155
+ };
156
+
157
+ const applySettings = () => {
158
+ const live = getConfig();
159
+ engine.updateConfig({
160
+ defaultMaxIterations: live.maxIterations,
161
+ autoDrive: live.autoDrive,
162
+ enableSound: live.enableSound,
163
+ });
17
164
  };
18
165
 
19
166
  // 1. Регистрация настроек плагина
20
167
  ctx.inject(['settings'], (sctx) => {
21
168
  try {
22
- const scope = sctx.settings?.register?.(NS, null, { base: currentSettings });
169
+ const scope = sctx.settings?.register?.(NS, Config, { base: currentSettings });
23
170
  if (scope) {
24
- currentSettings = { ...currentSettings, ...(scope.get?.() ?? {}) };
171
+ settingsScope = scope;
172
+ applySettings();
173
+ if (typeof scope.subscribe === 'function') {
174
+ sctx.effect(() => {
175
+ const off = scope.subscribe(() => {
176
+ applySettings();
177
+ });
178
+ return () => {
179
+ if (typeof off === 'function') off();
180
+ settingsScope = null;
181
+ };
182
+ }, 'dsh-goal: settings subscription');
183
+ }
25
184
  }
26
185
  } catch (err) {
27
186
  console.warn('[dsh-goal] Settings register skipped:', err.message);
28
187
  }
29
188
  });
30
189
 
31
- // 2. Регистрация инструментов для модели (tools)
190
+ // 2. Регистрация слэш-команды /goal в чате
191
+ ctx.inject(['commands'], (cctx) => {
192
+ try {
193
+ if (typeof cctx.commands?.register !== 'function') return;
194
+
195
+ const unregister = cctx.commands.register({
196
+ name: 'goal',
197
+ description: 'Управление режимом цели (Goal Mode): активация, вехи, пауза, сброс',
198
+ input: { hint: '[<цель>|clear|pause|resume]' },
199
+ handler: async (invocation) => {
200
+ if (invocation?.agent) {
201
+ lastActiveAgent = invocation.agent;
202
+ }
203
+ const parsed = parseGoalInput(invocation?.rawInput);
204
+ const liveConfig = getConfig();
205
+ return executeGoalSlashCommand(engine, parsed, liveConfig, invocation?.agent);
206
+ },
207
+ });
208
+
209
+ if (typeof unregister === 'function') {
210
+ cctx.effect(() => () => unregister(), 'dsh-goal: /goal command unregister');
211
+ }
212
+ } catch (err) {
213
+ console.warn('[dsh-goal] Commands register skipped:', err.message);
214
+ }
215
+ });
216
+
217
+ // 3. Инжекция контекста цели в системный промпт (systemPrompt)
218
+ ctx.inject(['systemPrompt'], (pctx) => {
219
+ try {
220
+ if (typeof pctx.systemPrompt?.section === 'function') {
221
+ const order = typeof pctx.systemPrompt.getSectionOrder === 'function'
222
+ ? (pctx.systemPrompt.getSectionOrder('TOOL_GOAL') || 600)
223
+ : 600;
224
+
225
+ const unregisterSection = pctx.systemPrompt.section({
226
+ name: 'tool:dsh-goal',
227
+ order,
228
+ text: () => engine.getStatePromptInjection(),
229
+ });
230
+
231
+ if (typeof unregisterSection === 'function') {
232
+ pctx.effect(() => () => unregisterSection(), 'dsh-goal: system prompt section');
233
+ }
234
+ }
235
+ } catch (err) {
236
+ console.warn('[dsh-goal] SystemPrompt section register skipped:', err.message);
237
+ }
238
+ });
239
+
240
+ // 4. Регистрация инструментов для модели (tools)
32
241
  ctx.inject(['tools'], (tctx) => {
33
242
  if (!tctx.tools?.register) return;
34
243
 
@@ -120,7 +329,7 @@ export function apply(ctx, config = {}) {
120
329
  });
121
330
  });
122
331
 
123
- // 3. Регистрация HTTP REST API маршрутов
332
+ // 5. Регистрация HTTP REST API маршрутов
124
333
  ctx.effect(() => {
125
334
  if (!ctx.webServer?.register) return () => {};
126
335
 
@@ -140,7 +349,29 @@ export function apply(ctx, config = {}) {
140
349
  }
141
350
 
142
351
  // POST /dsh-goal/action
352
+ // Issue #22: CSRF & Same-Origin guard
143
353
  if (req.method === 'POST' && (pathname === '/dsh-goal/action' || pathname === '/dsh-goal/action/')) {
354
+ const secFetchSite = req.headers['sec-fetch-site'];
355
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') {
356
+ res.statusCode = 403;
357
+ return res.end(JSON.stringify({ error: 'Forbidden: cross-site requests are rejected' }));
358
+ }
359
+
360
+ const origin = req.headers.origin;
361
+ const host = req.headers.host;
362
+ if (origin && host) {
363
+ try {
364
+ const originHost = new URL(origin).host;
365
+ if (originHost !== host) {
366
+ res.statusCode = 403;
367
+ return res.end(JSON.stringify({ error: 'Forbidden: origin mismatch' }));
368
+ }
369
+ } catch (_) {
370
+ res.statusCode = 403;
371
+ return res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
372
+ }
373
+ }
374
+
144
375
  let body = '';
145
376
  req.on('data', (chunk) => { body += chunk; });
146
377
  req.on('end', () => {
@@ -151,19 +382,26 @@ export function apply(ctx, config = {}) {
151
382
  let result = null;
152
383
  switch (action) {
153
384
  case 'start':
154
- result = engine.startGoal(title || 'Новая цель', { description });
385
+ result = engine.startGoal(title || 'Новая цель', {
386
+ description,
387
+ maxIterations: getConfig().maxIterations,
388
+ });
155
389
  break;
156
390
  case 'pause':
157
- result = engine.pause(reason);
391
+ result = engine.pause(reason || 'Пауза по кнопке интерфейса');
392
+ stopRunningAgents();
158
393
  break;
159
394
  case 'resume':
160
395
  result = engine.resume();
396
+ resumeActiveAgent();
161
397
  break;
162
398
  case 'cancel':
163
- result = engine.cancel(reason);
399
+ result = engine.cancel(reason || 'Отмена цели');
400
+ stopRunningAgents();
164
401
  break;
165
402
  case 'clear':
166
403
  result = engine.clear();
404
+ stopRunningAgents();
167
405
  break;
168
406
  case 'update_milestone':
169
407
  engine.updateMilestone(milestoneId, status, notes);
@@ -194,13 +432,41 @@ export function apply(ctx, config = {}) {
194
432
  };
195
433
  }, 'dsh-goal: HTTP WebServer Routes');
196
434
 
197
- // 4. Подписка на события сессии (автономный цикл)
435
+ // 6. Подписка на события сессии (автономный цикл)
198
436
  ctx.effect(() => {
199
437
  // Подписка на завершение turn
200
- const onTurnEnd = () => {
438
+ const onTurnEnd = (turn) => {
439
+ const liveConfig = getConfig();
440
+ if (!liveConfig.autoDrive) return; // Учитываем настройку autoDrive в рантайме
441
+
201
442
  const snap = engine.getSnapshot();
202
443
  if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
203
- engine.incrementIteration();
444
+ const canContinue = engine.incrementIteration();
445
+ if (!canContinue) return;
446
+
447
+ // Если turn не был прерван пользователем и цель ещё активна, отправляем следующий ход
448
+ if (turn?.reason?.kind !== 'aborted') {
449
+ setTimeout(() => {
450
+ const currentSnap = engine.getSnapshot();
451
+ if (currentSnap.hasActiveGoal && currentSnap.state === GoalState.RUNNING) {
452
+ const promptMsg = createGoalUserMessage(
453
+ 'Продолжай автономное выполнение цели согласно плану работ. Отмечай каждый выполненный шаг через goal_update_progress, а по завершении всех задач вызови goal_finish.',
454
+ );
455
+ let target = lastActiveAgent;
456
+ if (!target && agentsService && typeof agentsService.list === 'function') {
457
+ const list = agentsService.list();
458
+ if (list && list.length > 0) target = list[list.length - 1];
459
+ }
460
+ if (target && typeof target.followup === 'function') {
461
+ try {
462
+ target.followup(promptMsg);
463
+ } catch (err) {
464
+ console.warn('[dsh-goal] Failed auto-drive followup:', err);
465
+ }
466
+ }
467
+ }
468
+ }, 300);
469
+ }
204
470
  }
205
471
  };
206
472
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Goal mode & autonomous execution plugin for DeepSeek Harness with sticky top banner",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -44,14 +44,18 @@
44
44
  },
45
45
  "client": {
46
46
  "platform": "web",
47
- "inject": [
48
- "@deepseek-ai/dsh-client-runtime",
49
- "@deepseek-ai/dsh-client-ui-slots"
50
- ]
47
+ "inject": []
51
48
  }
52
49
  },
53
50
  "peerDependencies": {
54
51
  "@deepseek-ai/cordis": "^4.0.1",
55
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6"
52
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
53
+ "@deepseek-ai/schemastery": "^3.18.1"
54
+ },
55
+ "devDependencies": {
56
+ "@deepseek-ai/schemastery": "^3.18.1"
57
+ },
58
+ "dependencies": {
59
+ "@deepseek-ai/schemastery": "^3.18.1"
56
60
  }
57
61
  }