@goodandready/dsh-goal 0.1.6 → 0.1.8

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.
@@ -90,3 +90,29 @@
90
90
  - **Crash Hydration**: При перезапуске DSH цели, оставшиеся в статусе `RUNNING`, автоматически гидрируются в `PAUSED` с отметкой времени и пояснением оператору для ручного возобновления в 1 клик.
91
91
  - **Web Audio Синтез**: Синтезированные пентатонические колокольчики через `window.AudioContext` при завершении цели (`COMPLETED` — мажорный аккорд, `FAILED` — минорный аккорд) с соблюдением настройки `enableSound`.
92
92
  - **Milestone Integrity**: Статус выполнения вех строго контролируется агентом; чекбоксы ручной отметки в UI исключены в соответствии с архитектурным контрактом.
93
+
94
+ 8. **2026-09-12 — Сессионная изоляция /goal, устойчивость SSE и строгая REST валидация (v0.1.7)**:
95
+ - **Сквозная сессионная изоляция в слеш-команде**: В `command-handler.js` метод `executeGoalSlashCommand` пробрасывает `sessionId` во все вызовы `engine` (`getSnapshot`, `startGoal`, `pause`, `resume`, `clear`), исключая мутацию глобальной сессии при работе в конкретном чате.
96
+ - **Устойчивость SSE с Exponential Backoff**: На клиенте внедрена автоматическая схема повторного подключения `EventSource` с возрастающей задержкой (от 2s до 30s) и рандомизированным джиттером для защиты от шторма переподключений.
97
+ - **Строгая REST валидация и Enum Guard**: Обработчик `POST /dsh-goal/action` валидирует непустой `title` при старте цели и проверяет допустимость статуса вехи по `MilestoneStatus` enum, возвращая внятный HTTP 400 Bad Request / 404 Not Found.
98
+ - **Безопасность файловой системы**: `GoalEngine.writeStateToDiskSync()` гарантированно создаёт отсутствующие родительские директории через `fs.mkdirSync(dir, { recursive: true })` перед созданием временного файла и атомарным переименованием.
99
+
100
+ ### Решение 9: Интеллектуальная оценка времени (ETA), аналитика токенов, кнопка быстрого запуска цели и экспорт отчетов в Markdown
101
+
102
+ **Контекст:**
103
+ После стабилизации сессионной изоляции и SSE-потока пользователям требовалась наглядная оценка оставшегося времени работы над целью, понимание расхода токенов за цикл, удобный запуск цели без ручного ввода команды `/goal` и возможность мгновенно экспортировать структурированный итоговый отчёт в markdown-формате.
104
+
105
+ **Принятые решения:**
106
+ 1. **Расчет оставшегося времени (ETA projection):**
107
+ - Метод `getEstimatedRemainingSeconds(sid)` рассчитывает среднее время на выполнение завершенных вех `elapsed / completedCount` и умножает на количество оставшихся шагов.
108
+ - В верхнем баннере и модальном окне отображается живое время работы с прогнозом: `⏱ 45s (ETA ~2m)`.
109
+ 2. **Аналитика расхода токенов (Token Usage Analytics):**
110
+ - Накопление счетчиков `promptTokens`, `completionTokens`, `totalTokens` на каждом завершении хода `turn/end` через `engine.addTokenUsage(usage, sid)`.
111
+ - В модальном окне деталей в сетку характеристик добавлен блок «Токены» с подсказкой при наведении с детальным расщеплением.
112
+ 3. **Кнопка быстрого запуска цели (Quick Launch button):**
113
+ - Когда цель не активна, в `conversation.input.dock` отображается компактная кнопка с иконкой 🎯.
114
+ - По клику открывается модальное окно с полем ввода для немедленной постановки задачи агенту без ручного ввода слэш-команд.
115
+ - В карточке настроек добавлен переключатель `showQuickLaunchButton` с возможностью сброса к дефолту.
116
+ 4. **Экспорт отчета в Markdown (One-click Markdown Export):**
117
+ - Для выполненной цели модальное окно предоставляет кнопку «📋 Скопировать отчёт в Markdown».
118
+ - Генерирует отчет с заголовком, статусом, длительностью, итерациями, расходом токенов, резюме результатов и таблицей вех.
package/lib/client.js CHANGED
@@ -182,6 +182,32 @@ window.__ModuleLoader__.load({
182
182
  display: flex;
183
183
  justify-content: center;
184
184
  }
185
+ .dsh-goal-dock.dsh-goal-dock-quick {
186
+ justify-content: flex-start;
187
+ margin-bottom: 4px;
188
+ }
189
+ .dsh-goal-quicklaunch-btn {
190
+ appearance: none;
191
+ font: inherit;
192
+ background: var(--dsw-alias-bg-layer-2);
193
+ border: 1px solid var(--dsw-alias-border-l2);
194
+ border-radius: 999px;
195
+ padding: 4px 11px;
196
+ color: var(--dsw-alias-label-secondary);
197
+ font-size: 12px;
198
+ font-weight: 500;
199
+ cursor: pointer;
200
+ display: inline-flex;
201
+ align-items: center;
202
+ gap: 6px;
203
+ transition: all 0.15s ease;
204
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
205
+ }
206
+ .dsh-goal-quicklaunch-btn:hover {
207
+ background: var(--dsw-alias-bg-layer-3);
208
+ border-color: var(--dsw-alias-label-tertiary);
209
+ color: var(--dsw-alias-label-primary);
210
+ }
185
211
  .dsh-goal-banner {
186
212
  box-sizing: border-box;
187
213
  width: 100%;
@@ -382,7 +408,7 @@ window.__ModuleLoader__.load({
382
408
  }
383
409
  .dsh-goal-stat-grid {
384
410
  display: grid;
385
- grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
411
+ grid-template-columns: repeat(auto-fit, minmax(95px, 1fr));
386
412
  gap: 10px;
387
413
  }
388
414
  .dsh-goal-stat-box {
@@ -395,13 +421,17 @@ window.__ModuleLoader__.load({
395
421
  gap: 2px;
396
422
  }
397
423
  .dsh-goal-stat-val {
398
- font-size: 16px;
424
+ font-size: 15px;
399
425
  font-weight: 700;
400
426
  color: var(--dsw-alias-label-primary);
427
+ overflow: hidden;
428
+ text-overflow: ellipsis;
429
+ white-space: nowrap;
401
430
  }
402
431
  .dsh-goal-stat-lbl {
403
432
  font-size: 11px;
404
433
  color: var(--dsw-alias-label-secondary);
434
+ white-space: nowrap;
405
435
  }
406
436
  .dsh-goal-milestone-item {
407
437
  display: flex;
@@ -421,6 +451,17 @@ window.__ModuleLoader__.load({
421
451
  justify-content: flex-end;
422
452
  align-items: center;
423
453
  gap: 10px;
454
+ flex-wrap: wrap;
455
+ }
456
+ .dsh-goal-btn-copy {
457
+ background: var(--dsw-alias-bg-layer-2);
458
+ border: 1px solid var(--dsw-alias-border-l2);
459
+ color: var(--dsw-alias-label-primary);
460
+ }
461
+ .dsh-goal-btn-copy.copied {
462
+ background: rgba(16, 185, 129, 0.12);
463
+ border-color: var(--dsw-alias-state-success-primary);
464
+ color: var(--dsw-alias-state-success-primary);
424
465
  }
425
466
 
426
467
  /* Settings Card (clinebot pattern) */
@@ -658,12 +699,77 @@ window.__ModuleLoader__.load({
658
699
  } catch (_) {}
659
700
  }
660
701
 
702
+ // --- ГЕНЕРАТОР MARKDOWN-ОТЧЕТА ---
703
+ function generateMarkdownReport(state) {
704
+ if (!state) return '';
705
+ const title = state.title || 'Goal Report';
706
+ const status = state.state === 'COMPLETED' ? 'COMPLETED' : state.state;
707
+ const elapsed = state.formattedElapsed || '0s';
708
+ const iter = `${state.iterationsCount || 0}/${state.maxIterations || 25}`;
709
+ const totalTokens = state.tokensUsage?.totalTokens || 0;
710
+ const promptTokens = state.tokensUsage?.promptTokens || 0;
711
+ const compTokens = state.tokensUsage?.completionTokens || 0;
712
+
713
+ let md = `# 🎯 Goal Report: ${title}\n\n`;
714
+ md += `**Status:** \`${status}\` | **Duration:** \`${elapsed}\` | **Iterations:** \`${iter}\`\n`;
715
+ if (totalTokens > 0) {
716
+ md += `**Tokens:** \`${totalTokens.toLocaleString()}\` (Prompt: \`${promptTokens.toLocaleString()}\`, Completion: \`${compTokens.toLocaleString()}\`)\n`;
717
+ }
718
+ md += '\n';
719
+
720
+ if (state.description) {
721
+ md += `### Description\n${state.description}\n\n`;
722
+ }
723
+
724
+ if (state.resultSummary) {
725
+ md += `### Summary & Deliverables\n${state.resultSummary}\n\n`;
726
+ }
727
+
728
+ const milestones = state.milestones || [];
729
+ if (milestones.length > 0) {
730
+ md += `### Milestones\n| # | Status | Title | Notes |\n|---|---|---|---|\n`;
731
+ milestones.forEach((m, idx) => {
732
+ const mark = m.status === 'completed' ? '✅ Done' : m.status === 'in_progress' ? '🔄 In Progress' : '⏳ Pending';
733
+ const cleanTitle = (m.title || '').replace(/\|/g, '\\|');
734
+ const cleanNotes = (m.notes || '').replace(/\|/g, '\\|');
735
+ md += `| ${idx + 1} | ${mark} | ${cleanTitle} | ${cleanNotes || '—'} |\n`;
736
+ });
737
+ md += '\n';
738
+ }
739
+
740
+ md += `*Generated by DSH Goal Engine at ${new Date().toISOString()}*\n`;
741
+ return md;
742
+ }
743
+
661
744
  // --- МОДАЛЬНОЕ ОКНО ДЕТАЛЕЙ (MODAL DETAILS) ---
662
745
  function GoalDetailsModal({ state, onClose, onAction, t }) {
663
746
  if (!state) return null;
664
747
 
665
748
  const milestones = state.milestones || [];
666
749
  const isCompleted = state.state === 'COMPLETED';
750
+ const [copied, setCopied] = useState(false);
751
+
752
+ const totalTokens = state.tokensUsage?.totalTokens || 0;
753
+ const promptTokens = state.tokensUsage?.promptTokens || 0;
754
+ const compTokens = state.tokensUsage?.completionTokens || 0;
755
+ const tokensFormatted = totalTokens > 0
756
+ ? (totalTokens >= 1000 ? `${(totalTokens / 1000).toFixed(1)}k` : `${totalTokens}`)
757
+ : '—';
758
+ const tokensTooltip = totalTokens > 0
759
+ ? `Prompt: ${promptTokens.toLocaleString()} | Completion: ${compTokens.toLocaleString()} | Total: ${totalTokens.toLocaleString()}`
760
+ : 'Токены ещё не зафиксированы';
761
+
762
+ const handleCopyReport = () => {
763
+ const md = generateMarkdownReport(state);
764
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
765
+ navigator.clipboard.writeText(md).then(() => {
766
+ setCopied(true);
767
+ setTimeout(() => setCopied(false), 2500);
768
+ }).catch((err) => {
769
+ console.warn('[dsh-goal] Clipboard copy failed:', err);
770
+ });
771
+ }
772
+ };
667
773
 
668
774
  return React.createElement(
669
775
  'div',
@@ -713,6 +819,18 @@ window.__ModuleLoader__.load({
713
819
  React.createElement('span', { className: 'dsh-goal-stat-val' }, state.formattedElapsed || '0s'),
714
820
  React.createElement('span', { className: 'dsh-goal-stat-lbl' }, t('time') || 'Время работы'),
715
821
  ),
822
+ React.createElement(
823
+ 'div',
824
+ { className: 'dsh-goal-stat-box' },
825
+ React.createElement('span', { className: 'dsh-goal-stat-val' }, state.formattedETA || (isCompleted ? 'Готово' : '—')),
826
+ React.createElement('span', { className: 'dsh-goal-stat-lbl' }, t('eta') || 'Оценка (ETA)'),
827
+ ),
828
+ React.createElement(
829
+ 'div',
830
+ { className: 'dsh-goal-stat-box', title: tokensTooltip },
831
+ React.createElement('span', { className: 'dsh-goal-stat-val' }, tokensFormatted),
832
+ React.createElement('span', { className: 'dsh-goal-stat-lbl' }, t('tokens') || 'Токены'),
833
+ ),
716
834
  React.createElement(
717
835
  'div',
718
836
  { className: 'dsh-goal-stat-box' },
@@ -760,6 +878,17 @@ window.__ModuleLoader__.load({
760
878
  React.createElement(
761
879
  'div',
762
880
  { className: 'dsh-goal-modal-foot' },
881
+ isCompleted
882
+ ? React.createElement(
883
+ 'button',
884
+ {
885
+ className: `dsh-goal-btn dsh-goal-btn-copy${copied ? ' copied' : ''}`,
886
+ onClick: handleCopyReport,
887
+ title: t('copyReport') || 'Скопировать отчёт в Markdown',
888
+ },
889
+ copied ? (t('reportCopied') || '✅ Скопировано!') : (t('copyReport') || '📋 Скопировать отчёт в Markdown'),
890
+ )
891
+ : null,
763
892
  isCompleted
764
893
  ? React.createElement(
765
894
  'button',
@@ -786,6 +915,98 @@ window.__ModuleLoader__.load({
786
915
  );
787
916
  }
788
917
 
918
+ // --- МОДАЛЬНОЕ ОКНО БЫСТРОГО ЗАПУСКА ЦЕЛИ (QUICK LAUNCH MODAL) ---
919
+ function QuickLaunchModal({ onClose, onStart, t }) {
920
+ const [title, setTitle] = useState('');
921
+ const inputRef = useRef(null);
922
+
923
+ useEffect(() => {
924
+ if (inputRef.current) inputRef.current.focus();
925
+ }, []);
926
+
927
+ const handleSubmit = (e) => {
928
+ if (e) e.preventDefault();
929
+ const trimmed = title.trim();
930
+ if (trimmed) {
931
+ onStart(trimmed);
932
+ }
933
+ };
934
+
935
+ return React.createElement(
936
+ 'div',
937
+ { className: 'dsh-goal-modal-overlay', onClick: onClose },
938
+ React.createElement(
939
+ 'div',
940
+ {
941
+ className: 'dsh-goal-modal',
942
+ style: { maxWidth: 480 },
943
+ onClick: (e) => e.stopPropagation(),
944
+ },
945
+ React.createElement(
946
+ 'div',
947
+ { className: 'dsh-goal-modal-head' },
948
+ React.createElement(
949
+ 'div',
950
+ { className: 'dsh-goal-modal-title' },
951
+ React.createElement(IconTarget, { size: 18 }),
952
+ t('quickLaunchTitle') || 'Быстрый запуск цели',
953
+ ),
954
+ React.createElement(
955
+ 'button',
956
+ { className: 'dsh-goal-btn icon-only close', onClick: onClose, title: t('close') || 'Закрыть' },
957
+ '✕',
958
+ ),
959
+ ),
960
+ React.createElement(
961
+ 'form',
962
+ { onSubmit: handleSubmit },
963
+ React.createElement(
964
+ 'div',
965
+ { className: 'dsh-goal-modal-body' },
966
+ React.createElement(
967
+ 'div',
968
+ { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
969
+ t('quickLaunchDesc') || 'Сформулируйте задачу для агента в автономном режиме:',
970
+ ),
971
+ React.createElement('input', {
972
+ ref: inputRef,
973
+ type: 'text',
974
+ className: 'dsh-goal-card-input',
975
+ style: { width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '8px 10px' },
976
+ placeholder: t('quickLaunchPlaceholder') || 'Например: Реализовать экспорт отчетов и покрыть тестами...',
977
+ value: title,
978
+ onChange: (e) => setTitle(e.target.value),
979
+ onKeyDown: (e) => {
980
+ if (e.key === 'Enter' && !e.shiftKey) {
981
+ e.preventDefault();
982
+ handleSubmit();
983
+ }
984
+ },
985
+ }),
986
+ ),
987
+ React.createElement(
988
+ 'div',
989
+ { className: 'dsh-goal-modal-foot' },
990
+ React.createElement(
991
+ 'button',
992
+ { type: 'button', className: 'dsh-goal-btn', onClick: onClose },
993
+ t('close') || 'Отмена',
994
+ ),
995
+ React.createElement(
996
+ 'button',
997
+ {
998
+ type: 'submit',
999
+ className: 'dsh-goal-btn dsh-goal-btn-primary',
1000
+ disabled: !title.trim(),
1001
+ },
1002
+ t('startGoal') || 'Запустить цель',
1003
+ ),
1004
+ ),
1005
+ ),
1006
+ ),
1007
+ );
1008
+ }
1009
+
789
1010
  function sessionIdOf(ctx, props) {
790
1011
  try {
791
1012
  if (props?.session?.id) return String(props.session.id);
@@ -809,6 +1030,7 @@ window.__ModuleLoader__.load({
809
1030
  const sid = sessionIdOf(ctx, props);
810
1031
  const [state, setState] = useState(null);
811
1032
  const [isModalOpen, setIsModalOpen] = useState(false);
1033
+ const [isQuickLaunchOpen, setIsQuickLaunchOpen] = useState(false);
812
1034
  const [now, setNow] = useState(() => Date.now());
813
1035
  const stateRef = useRef(null);
814
1036
  stateRef.current = state;
@@ -869,8 +1091,10 @@ window.__ModuleLoader__.load({
869
1091
  useEffect(() => {
870
1092
  let es = null;
871
1093
  let timer = null;
1094
+ let reconnectTimer = null;
872
1095
  let isDisposed = false;
873
1096
  let sseActive = false;
1097
+ let retryCount = 0;
874
1098
 
875
1099
  const scheduleNextPoll = () => {
876
1100
  if (isDisposed || sseActive) return;
@@ -891,18 +1115,33 @@ window.__ModuleLoader__.load({
891
1115
  }, delay);
892
1116
  };
893
1117
 
894
- // Запуск SSE соединения
895
- if (typeof window !== 'undefined' && typeof window.EventSource !== 'undefined') {
1118
+ const connectSSE = () => {
1119
+ if (isDisposed) return;
1120
+ if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') {
1121
+ scheduleNextPoll();
1122
+ return;
1123
+ }
1124
+
896
1125
  try {
1126
+ if (es) {
1127
+ es.close();
1128
+ es = null;
1129
+ }
1130
+
897
1131
  const query = sid && sid !== 'default' ? `?sessionId=${encodeURIComponent(sid)}` : '';
898
1132
  es = new window.EventSource(`/dsh-goal/events${query}`);
899
1133
 
900
1134
  es.onopen = () => {
901
1135
  sseActive = true;
1136
+ retryCount = 0;
902
1137
  if (timer) {
903
1138
  clearTimeout(timer);
904
1139
  timer = null;
905
1140
  }
1141
+ if (reconnectTimer) {
1142
+ clearTimeout(reconnectTimer);
1143
+ reconnectTimer = null;
1144
+ }
906
1145
  };
907
1146
 
908
1147
  es.onmessage = (event) => {
@@ -916,22 +1155,38 @@ window.__ModuleLoader__.load({
916
1155
  };
917
1156
 
918
1157
  es.onerror = () => {
919
- // При сбое соединения SSE переключаемся на адаптивный fallback поллинг
920
1158
  sseActive = false;
921
- if (!isDisposed && !timer) {
922
- scheduleNextPoll();
1159
+ if (es) {
1160
+ es.close();
1161
+ es = null;
1162
+ }
1163
+ if (!isDisposed) {
1164
+ // Запускаем немедленный опрос для непрерывности работы UI
1165
+ if (!timer) scheduleNextPoll();
1166
+
1167
+ // Экспоненциальный backoff для повторного подключения SSE (от 2s до 30s) с джиттером
1168
+ retryCount = Math.min(retryCount + 1, 5);
1169
+ const backoffBase = Math.min(30000, 2000 * Math.pow(1.8, retryCount - 1));
1170
+ const jitter = Math.random() * 1000;
1171
+ const reconnectDelay = Math.round(backoffBase + jitter);
1172
+
1173
+ if (reconnectTimer) clearTimeout(reconnectTimer);
1174
+ reconnectTimer = setTimeout(() => {
1175
+ reconnectTimer = null;
1176
+ connectSSE();
1177
+ }, reconnectDelay);
923
1178
  }
924
1179
  };
925
1180
  } catch (_) {
926
1181
  sseActive = false;
927
1182
  scheduleNextPoll();
928
1183
  }
929
- } else {
930
- scheduleNextPoll();
931
- }
1184
+ };
1185
+
1186
+ connectSSE();
932
1187
 
933
1188
  // Первоначальная загрузка состояния
934
- fetchState().then((data) => {
1189
+ fetchState().then(() => {
935
1190
  if (!sseActive) scheduleNextPoll();
936
1191
  });
937
1192
 
@@ -942,6 +1197,10 @@ window.__ModuleLoader__.load({
942
1197
  fetchState().then(() => {
943
1198
  scheduleNextPoll();
944
1199
  });
1200
+ // При возвращении на вкладку можно попытаться сразу переподключить SSE
1201
+ if (!reconnectTimer && !sseActive) {
1202
+ connectSSE();
1203
+ }
945
1204
  }
946
1205
  }
947
1206
  };
@@ -957,6 +1216,7 @@ window.__ModuleLoader__.load({
957
1216
  es = null;
958
1217
  }
959
1218
  if (timer) clearTimeout(timer);
1219
+ if (reconnectTimer) clearTimeout(reconnectTimer);
960
1220
  if (typeof document !== 'undefined' && document.removeEventListener) {
961
1221
  document.removeEventListener('visibilitychange', onVisibilityChange);
962
1222
  }
@@ -980,8 +1240,38 @@ window.__ModuleLoader__.load({
980
1240
  } catch (_) {}
981
1241
  };
982
1242
 
1243
+ const showQuickLaunch = state ? (state.showQuickLaunchButton !== false) : true;
1244
+
983
1245
  if (!state || !state.hasActiveGoal) {
984
- return null;
1246
+ if (!showQuickLaunch) return null;
1247
+ return React.createElement(
1248
+ React.Fragment,
1249
+ null,
1250
+ React.createElement(
1251
+ 'div',
1252
+ { className: 'dsh-goal-dock dsh-goal-dock-quick', 'data-goal-dock': 'true' },
1253
+ React.createElement(
1254
+ 'button',
1255
+ {
1256
+ className: 'dsh-goal-quicklaunch-btn',
1257
+ title: t('quickLaunch') || 'Запустить цель (Goal Mode)',
1258
+ onClick: () => setIsQuickLaunchOpen(true),
1259
+ },
1260
+ React.createElement(IconTarget, { size: 14 }),
1261
+ React.createElement('span', null, t('quickLaunch') || 'Запустить цель'),
1262
+ ),
1263
+ ),
1264
+ isQuickLaunchOpen
1265
+ ? React.createElement(QuickLaunchModal, {
1266
+ onClose: () => setIsQuickLaunchOpen(false),
1267
+ onStart: async (title) => {
1268
+ await handleAction('start', { title });
1269
+ setIsQuickLaunchOpen(false);
1270
+ },
1271
+ t,
1272
+ })
1273
+ : null,
1274
+ );
985
1275
  }
986
1276
 
987
1277
  const isPaused = state.state === 'PAUSED';
@@ -996,6 +1286,7 @@ window.__ModuleLoader__.load({
996
1286
  };
997
1287
 
998
1288
  const liveElapsedStr = formatLiveElapsed();
1289
+ const etaStr = state?.formattedETA ? ` (ETA ${state.formattedETA})` : '';
999
1290
 
1000
1291
  return React.createElement(
1001
1292
  React.Fragment,
@@ -1029,7 +1320,7 @@ window.__ModuleLoader__.load({
1029
1320
  React.createElement(
1030
1321
  'span',
1031
1322
  { className: 'dsh-goal-time' },
1032
- `⏱ ${liveElapsedStr}`,
1323
+ `⏱ ${liveElapsedStr}${etaStr}`,
1033
1324
  ),
1034
1325
  !isCompleted
1035
1326
  ? React.createElement(
@@ -1081,6 +1372,7 @@ window.__ModuleLoader__.load({
1081
1372
  maxIterations: 25,
1082
1373
  autoDrive: true,
1083
1374
  enableSound: true,
1375
+ showQuickLaunchButton: true,
1084
1376
  };
1085
1377
 
1086
1378
  function parseNumberField(raw) {
@@ -1139,7 +1431,7 @@ window.__ModuleLoader__.load({
1139
1431
  function computeSavePlan(draft, snap) {
1140
1432
  if (!draft) return [];
1141
1433
 
1142
- const fields = ['maxIterations', 'autoDrive', 'enableSound'];
1434
+ const fields = ['maxIterations', 'autoDrive', 'enableSound', 'showQuickLaunchButton'];
1143
1435
  const writes = [];
1144
1436
 
1145
1437
  for (const f of fields) {
@@ -1196,8 +1488,9 @@ window.__ModuleLoader__.load({
1196
1488
  const maxIterStatus = getFieldStatus('maxIterations', draft?.maxIterations, snap);
1197
1489
  const autoDriveStatus = getFieldStatus('autoDrive', draft?.autoDrive, snap);
1198
1490
  const enableSoundStatus = getFieldStatus('enableSound', draft?.enableSound, snap);
1491
+ const quickLaunchStatus = getFieldStatus('showQuickLaunchButton', draft?.showQuickLaunchButton, snap);
1199
1492
 
1200
- const dirty = maxIterStatus.isDirty || autoDriveStatus.isDirty || enableSoundStatus.isDirty;
1493
+ const dirty = maxIterStatus.isDirty || autoDriveStatus.isDirty || enableSoundStatus.isDirty || quickLaunchStatus.isDirty;
1201
1494
  const invalid = maxIterStatus.invalid;
1202
1495
  const disabled = !scope || saving || snap?.writable === false;
1203
1496
 
@@ -1207,6 +1500,7 @@ window.__ModuleLoader__.load({
1207
1500
  maxIterations: prev?.maxIterations !== undefined ? prev.maxIterations : maxIterStatus.value,
1208
1501
  autoDrive: prev?.autoDrive !== undefined ? prev.autoDrive : autoDriveStatus.value,
1209
1502
  enableSound: prev?.enableSound !== undefined ? prev.enableSound : enableSoundStatus.value,
1503
+ showQuickLaunchButton: prev?.showQuickLaunchButton !== undefined ? prev.showQuickLaunchButton : quickLaunchStatus.value,
1210
1504
  [field]: val,
1211
1505
  }));
1212
1506
  };
@@ -1380,6 +1674,33 @@ window.__ModuleLoader__.load({
1380
1674
  )
1381
1675
  : null,
1382
1676
  ),
1677
+ React.createElement(
1678
+ 'div',
1679
+ { className: 'dsh-goal-check-row' },
1680
+ React.createElement(
1681
+ 'label',
1682
+ { className: 'dsh-goal-check' },
1683
+ React.createElement('input', {
1684
+ type: 'checkbox',
1685
+ checked: quickLaunchStatus.value !== false,
1686
+ disabled,
1687
+ onChange: (e) => edit('showQuickLaunchButton', e.target.checked),
1688
+ }),
1689
+ t('quickLaunchLabel') || 'Кнопка быстрого запуска цели над полем ввода',
1690
+ ),
1691
+ quickLaunchStatus.isOverridden
1692
+ ? React.createElement(
1693
+ 'button',
1694
+ {
1695
+ type: 'button',
1696
+ className: 'dsh-goal-btn-inline-reset',
1697
+ title: t('resetField') || 'Сбросить к значению по умолчанию',
1698
+ onClick: () => resetFieldToDefault('showQuickLaunchButton'),
1699
+ },
1700
+ React.createElement(IconRotateCcw, { size: 12 }),
1701
+ )
1702
+ : null,
1703
+ ),
1383
1704
  React.createElement(
1384
1705
  'div',
1385
1706
  { className: 'dsh-goal-foot' },
@@ -1423,6 +1744,8 @@ window.__ModuleLoader__.load({
1423
1744
  modalTitle: 'План и статус цели',
1424
1745
  modalTitleCompleted: 'Цель выполнена: план и результаты',
1425
1746
  time: 'Время работы',
1747
+ eta: 'Оценка (ETA)',
1748
+ tokens: 'Токены',
1426
1749
  milestones: 'План работ:',
1427
1750
  noMilestones: 'Агент формирует план работ...',
1428
1751
  close: 'Закрыть',
@@ -1431,6 +1754,14 @@ window.__ModuleLoader__.load({
1431
1754
  maxIterLabel: 'Максимум итераций (Safety Limit):',
1432
1755
  autoDriveLabel: 'Авто-драйв: продолжать цикл автоматически',
1433
1756
  soundLabel: 'Звук по завершении цели',
1757
+ quickLaunchLabel: 'Кнопка быстрого запуска цели над полем ввода',
1758
+ quickLaunch: 'Запустить цель',
1759
+ quickLaunchTitle: 'Быстрый запуск цели',
1760
+ quickLaunchDesc: 'Сформулируйте задачу для агента в автономном режиме:',
1761
+ quickLaunchPlaceholder: 'Например: Реализовать экспорт отчетов и покрыть тестами...',
1762
+ startGoal: 'Запустить цель',
1763
+ copyReport: '📋 Скопировать отчёт в Markdown',
1764
+ reportCopied: '✅ Скопировано!',
1434
1765
  save: 'Сохранить',
1435
1766
  saving: 'Сохранение…',
1436
1767
  discard: 'Отменить правки',
@@ -1451,6 +1782,8 @@ window.__ModuleLoader__.load({
1451
1782
  modalTitle: 'Goal Plan & Status',
1452
1783
  modalTitleCompleted: 'Goal Completed: Plan & Results',
1453
1784
  time: 'Running time',
1785
+ eta: 'ETA',
1786
+ tokens: 'Tokens',
1454
1787
  milestones: 'Plan of work:',
1455
1788
  noMilestones: 'Agent is preparing the plan of work...',
1456
1789
  close: 'Close',
@@ -1459,6 +1792,14 @@ window.__ModuleLoader__.load({
1459
1792
  maxIterLabel: 'Max iterations (Safety Limit):',
1460
1793
  autoDriveLabel: 'Auto-drive: keep the loop running automatically',
1461
1794
  soundLabel: 'Sound when a goal completes',
1795
+ quickLaunchLabel: 'Quick launch goal button above composer dock',
1796
+ quickLaunch: 'Start Goal',
1797
+ quickLaunchTitle: 'Quick Launch Goal',
1798
+ quickLaunchDesc: 'Define the objective for the agent in autonomous mode:',
1799
+ quickLaunchPlaceholder: 'e.g. Implement report export and cover with unit tests...',
1800
+ startGoal: 'Start Goal',
1801
+ copyReport: '📋 Copy Report in Markdown',
1802
+ reportCopied: '✅ Copied!',
1462
1803
  save: 'Save',
1463
1804
  saving: 'Saving…',
1464
1805
  discard: 'Discard changes',
@@ -1,192 +1,199 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { GoalState } from './goal-engine.js';
3
-
4
- export const USAGE = 'Использование: /goal [<цель>|clear|pause|resume]';
5
-
6
- /**
7
- * Парсинг строки ввода для команды /goal
8
- * @param {string} rawInput
9
- * @returns {{ action: string, text?: string }}
10
- */
11
- export function parseGoalInput(rawInput = '') {
12
- const input = String(rawInput || '').trim();
13
- if (!input) {
14
- return { action: 'show' };
15
- }
16
-
17
- const lower = input.toLowerCase();
18
- if (lower === 'clear') return { action: 'clear' };
19
- if (lower === 'pause') return { action: 'pause' };
20
- if (lower === 'resume') return { action: 'resume' };
21
-
22
- return { action: 'start', text: input };
23
- }
24
-
25
- /**
26
- * Создание валидного identified сообщения пользователя для отправки агенту через followup
27
- * @param {string} text
28
- * @returns {Object}
29
- */
30
- export function createGoalUserMessage(text) {
31
- return {
32
- id: randomUUID(),
33
- role: 'user',
34
- content: [{ type: 'text', text }],
35
- source: { kind: 'user' },
36
- };
37
- }
38
-
39
- /**
40
- * Формирование стартового промпта с жестким требованием составить план работ
41
- * @param {string} goalText
42
- * @returns {string}
43
- */
44
- export function formatGoalStartPrompt(goalText) {
45
- return `🎯 Активирован режим цели (Goal Mode): "${goalText}"\n\n` +
46
- `СТРОГИЙ КОНТРАКТ АВТОНОМНОГО РЕЖИМА:\n` +
47
- `1. ОБЯЗАТЕЛЬНЫЙ ШАГ №1: Твоим ПЕРВЫМ действием ДО выполнения любой другой работы или ответа должен быть вызов инструмента goal_set_milestones с массивом из 3-7 конкретных, последовательных пунктов плана работ. Пользователь видит этот план в интерфейсе в реальном времени.\n` +
48
- `2. ШАГ №2: После установки плана переходи к выполнению шагов. Перед началом работы над каждым шагом отметь его статус как in_progress через goal_update_progress(milestone_id, "in_progress").\n` +
49
- `3. ШАГ №3: После успешного завершения шага отметь его статус как completed через goal_update_progress(milestone_id, "completed", "краткие итоги шага").\n` +
50
- `4. ШАГ №4: Когда все пункты плана выполнены, вызови инструмент goal_finish(summary) с подробным резюме достигнутых результатов.`;
51
- }
52
-
53
- /**
54
- * Форматирование текстового ответа для UI и постановка/управление задачей агента
55
- * @param {import('./goal-engine.js').GoalEngine} engine
56
- * @param {{ action: string, text?: string }} parsed
57
- * @param {Object} [config]
58
- * @param {Object} [agent]
59
- * @returns {{ kind: 'success' | 'error', text: string }}
60
- */
61
- export function executeGoalSlashCommand(engine, parsed, config = {}, agent = null, sessionId = 'default') {
62
- const sid = sessionId || 'default';
63
- const snap = engine.getSnapshot(sid);
64
-
65
- switch (parsed.action) {
66
- case 'show': {
67
- if (!snap.hasActiveGoal) {
68
- return {
69
- kind: 'success',
70
- text: `🎯 Режим цели (Goal Mode): цель не установлена.\n${USAGE}`,
71
- };
72
- }
73
-
74
- const milestonesInfo = snap.milestones.length > 0
75
- ? `\nПлан работ:\n` + snap.milestones.map((m, i) => ` ${i + 1}. [${m.status}] ${m.title}`).join('\n')
76
- : '';
77
-
78
- return {
79
- kind: 'success',
80
- text: `🎯 Цель: «${snap.title}»\n` +
81
- `Статус: ${snap.state}\n` +
82
- `Время: ${snap.formattedElapsed}\n` +
83
- `Итераций: ${snap.iterationsCount}/${snap.maxIterations}` +
84
- milestonesInfo +
85
- `\n\nКоманды: /goal pause, /goal resume, /goal clear, /goal <новая цель>`,
86
- };
87
- }
88
-
89
- case 'clear': {
90
- if (!snap.hasActiveGoal) {
91
- return {
92
- kind: 'success',
93
- text: 'Цель не была установлена.',
94
- };
95
- }
96
- engine.clear(sid);
97
- if (agent && typeof agent.cancel === 'function') {
98
- try {
99
- agent.cancel({ kind: 'user', reason: 'Goal cleared by user' });
100
- } catch (err) {
101
- console.warn('[dsh-goal] Failed to cancel agent on clear:', err);
102
- }
103
- }
104
- return {
105
- kind: 'success',
106
- text: '🎯 Цель сброшена.',
107
- };
108
- }
109
-
110
- case 'pause': {
111
- if (!snap.hasActiveGoal) {
112
- return {
113
- kind: 'error',
114
- text: `Нельзя приостановить: активная цель отсутствует.\n${USAGE}`,
115
- };
116
- }
117
- if (snap.state === GoalState.PAUSED) {
118
- return {
119
- kind: 'success',
120
- text: '⏸ Цель уже на паузе.',
121
- };
122
- }
123
- engine.pause('Пауза по команде пользователя (/goal pause)', sid);
124
- if (agent && typeof agent.cancel === 'function') {
125
- try {
126
- agent.cancel({ kind: 'user', reason: 'Goal paused by user' });
127
- } catch (err) {
128
- console.warn('[dsh-goal] Failed to cancel agent on pause:', err);
129
- }
130
- }
131
- return {
132
- kind: 'success',
133
- text: `⏸ Цель приостановлена: «${snap.title}». Работа агента остановлена. Используйте /goal resume или кнопку ▶️ для продолжения.`,
134
- };
135
- }
136
-
137
- case 'resume': {
138
- if (!snap.hasActiveGoal) {
139
- return {
140
- kind: 'error',
141
- text: `Нельзя возобновить: активная цель отсутствует.\n${USAGE}`,
142
- };
143
- }
144
- if (snap.state === GoalState.RUNNING) {
145
- return {
146
- kind: 'success',
147
- text: '▶️ Цель уже выполняется.',
148
- };
149
- }
150
- engine.resume(sid);
151
- if (agent && typeof agent.followup === 'function') {
152
- try {
153
- const resumePrompt = `▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.`;
154
- agent.followup(createGoalUserMessage(resumePrompt));
155
- } catch (err) {
156
- console.warn('[dsh-goal] Failed to followup agent on resume:', err);
157
- }
158
- }
159
- return {
160
- kind: 'success',
161
- text: `▶️ Цель возобновлена: «${snap.title}». Агент продолжает работу.`,
162
- };
163
- }
164
-
165
- case 'start': {
166
- const maxIterations = config.maxIterations ?? snap.maxIterations ?? 25;
167
- const newSnap = engine.startGoal(parsed.text, { maxIterations }, sid);
168
-
169
- // Если команда вызвана в контексте агента, отправляем цель в очередь модели
170
- if (agent && typeof agent.followup === 'function') {
171
- try {
172
- const userMsg = createGoalUserMessage(formatGoalStartPrompt(parsed.text));
173
- agent.followup(userMsg);
174
- } catch (err) {
175
- console.warn('[dsh-goal] Failed to dispatch goal to agent followup:', err);
176
- }
177
- }
178
-
179
- return {
180
- kind: 'success',
181
- text: `🎯 Активирована цель: «${newSnap.title}» (макс. ${newSnap.maxIterations} итераций).\n` +
182
- `Для паузы: /goal pause | Для сброса: /goal clear`,
183
- };
184
- }
185
-
186
- default:
187
- return {
188
- kind: 'error',
189
- text: `Неизвестная команда. ${USAGE}`,
190
- };
191
- }
192
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { GoalState } from './goal-engine.js';
3
+
4
+ export const USAGE = 'Использование: /goal [<цель>|clear|pause|resume]';
5
+
6
+ /**
7
+ * Парсинг строки ввода для команды /goal
8
+ * @param {string} rawInput
9
+ * @returns {{ action: string, text?: string }}
10
+ */
11
+ export function parseGoalInput(rawInput = '') {
12
+ const input = String(rawInput || '').trim();
13
+ if (!input) {
14
+ return { action: 'show' };
15
+ }
16
+
17
+ const lower = input.toLowerCase();
18
+ if (lower === 'clear') return { action: 'clear' };
19
+ if (lower === 'pause') return { action: 'pause' };
20
+ if (lower === 'resume') return { action: 'resume' };
21
+
22
+ return { action: 'start', text: input };
23
+ }
24
+
25
+ /**
26
+ * Создание валидного identified сообщения пользователя для отправки агенту через followup
27
+ * @param {string} text
28
+ * @returns {Object}
29
+ */
30
+ export function createGoalUserMessage(text) {
31
+ return {
32
+ id: randomUUID(),
33
+ role: 'user',
34
+ content: [{ type: 'text', text }],
35
+ source: { kind: 'user' },
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Формирование стартового промпта с жестким требованием составить план работ
41
+ * @param {string} goalText
42
+ * @returns {string}
43
+ */
44
+ export function formatGoalStartPrompt(goalText) {
45
+ return `🎯 Активирован режим цели (Goal Mode): "${goalText}"\n\n` +
46
+ `СТРОГИЙ КОНТРАКТ АВТОНОМНОГО РЕЖИМА:\n` +
47
+ `1. ОБЯЗАТЕЛЬНЫЙ ШАГ №1: Твоим ПЕРВЫМ действием ДО выполнения любой другой работы или ответа должен быть вызов инструмента goal_set_milestones с массивом из 3-7 конкретных, последовательных пунктов плана работ. Пользователь видит этот план в интерфейсе в реальном времени.\n` +
48
+ `2. ШАГ №2: После установки плана переходи к выполнению шагов. Перед началом работы над каждым шагом отметь его статус как in_progress через goal_update_progress(milestone_id, "in_progress").\n` +
49
+ `3. ШАГ №3: После успешного завершения шага отметь его статус как completed через goal_update_progress(milestone_id, "completed", "краткие итоги шага").\n` +
50
+ `4. ШАГ №4: Когда все пункты плана выполнены, вызови инструмент goal_finish(summary) с подробным резюме достигнутых результатов.`;
51
+ }
52
+
53
+ /**
54
+ * Форматирование текстового ответа для UI и постановка/управление задачей агента
55
+ * @param {import('./goal-engine.js').GoalEngine} engine
56
+ * @param {{ action: string, text?: string }} parsed
57
+ * @param {Object} [config]
58
+ * @param {Object} [agent]
59
+ * @param {string} [sessionId='default']
60
+ * @returns {{ kind: 'success' | 'error', text: string }}
61
+ */
62
+ export function executeGoalSlashCommand(engine, parsed, config = {}, agent = null, sessionId = 'default') {
63
+ const sid = sessionId || 'default';
64
+ const snap = engine.getSnapshot(sid);
65
+
66
+ switch (parsed.action) {
67
+ case 'show': {
68
+ if (!snap.hasActiveGoal) {
69
+ return {
70
+ kind: 'success',
71
+ text: `🎯 Режим цели (Goal Mode): цель не установлена.\n${USAGE}`,
72
+ };
73
+ }
74
+
75
+ const milestonesInfo = snap.milestones.length > 0
76
+ ? `\nПлан работ:\n` + snap.milestones.map((m, i) => ` ${i + 1}. [${m.status}] ${m.title}`).join('\n')
77
+ : '';
78
+
79
+ const etaText = snap.formattedETA ? ` (ETA: ${snap.formattedETA})` : '';
80
+ const tokensInfo = (snap.tokensUsage && snap.tokensUsage.totalTokens > 0)
81
+ ? `\nТокены: ${snap.tokensUsage.totalTokens.toLocaleString()} (in: ${snap.tokensUsage.promptTokens.toLocaleString()}, out: ${snap.tokensUsage.completionTokens.toLocaleString()})`
82
+ : '';
83
+
84
+ return {
85
+ kind: 'success',
86
+ text: `🎯 Цель: «${snap.title}»\n` +
87
+ `Статус: ${snap.state}\n` +
88
+ `Время: ${snap.formattedElapsed}${etaText}\n` +
89
+ `Итераций: ${snap.iterationsCount}/${snap.maxIterations}` +
90
+ tokensInfo +
91
+ milestonesInfo +
92
+ `\n\nКоманды: /goal pause, /goal resume, /goal clear, /goal <новая цель>`,
93
+ };
94
+ }
95
+
96
+ case 'clear': {
97
+ if (!snap.hasActiveGoal) {
98
+ return {
99
+ kind: 'success',
100
+ text: 'Цель не была установлена.',
101
+ };
102
+ }
103
+ engine.clear(sid);
104
+ if (agent && typeof agent.cancel === 'function') {
105
+ try {
106
+ agent.cancel({ kind: 'user', reason: 'Goal cleared by user' });
107
+ } catch (err) {
108
+ console.warn('[dsh-goal] Failed to cancel agent on clear:', err);
109
+ }
110
+ }
111
+ return {
112
+ kind: 'success',
113
+ text: '🎯 Цель сброшена.',
114
+ };
115
+ }
116
+
117
+ case 'pause': {
118
+ if (!snap.hasActiveGoal) {
119
+ return {
120
+ kind: 'error',
121
+ text: `Нельзя приостановить: активная цель отсутствует.\n${USAGE}`,
122
+ };
123
+ }
124
+ if (snap.state === GoalState.PAUSED) {
125
+ return {
126
+ kind: 'success',
127
+ text: '⏸ Цель уже на паузе.',
128
+ };
129
+ }
130
+ engine.pause('Пауза по команде пользователя (/goal pause)', sid);
131
+ if (agent && typeof agent.cancel === 'function') {
132
+ try {
133
+ agent.cancel({ kind: 'user', reason: 'Goal paused by user' });
134
+ } catch (err) {
135
+ console.warn('[dsh-goal] Failed to cancel agent on pause:', err);
136
+ }
137
+ }
138
+ return {
139
+ kind: 'success',
140
+ text: `⏸ Цель приостановлена: «${snap.title}». Работа агента остановлена. Используйте /goal resume или кнопку ▶️ для продолжения.`,
141
+ };
142
+ }
143
+
144
+ case 'resume': {
145
+ if (!snap.hasActiveGoal) {
146
+ return {
147
+ kind: 'error',
148
+ text: `Нельзя возобновить: активная цель отсутствует.\n${USAGE}`,
149
+ };
150
+ }
151
+ if (snap.state === GoalState.RUNNING) {
152
+ return {
153
+ kind: 'success',
154
+ text: '▶️ Цель уже выполняется.',
155
+ };
156
+ }
157
+ engine.resume(sid);
158
+ if (agent && typeof agent.followup === 'function') {
159
+ try {
160
+ const resumePrompt = `▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.`;
161
+ agent.followup(createGoalUserMessage(resumePrompt));
162
+ } catch (err) {
163
+ console.warn('[dsh-goal] Failed to followup agent on resume:', err);
164
+ }
165
+ }
166
+ return {
167
+ kind: 'success',
168
+ text: `▶️ Цель возобновлена: «${snap.title}». Агент продолжает работу.`,
169
+ };
170
+ }
171
+
172
+ case 'start': {
173
+ const maxIterations = config.maxIterations ?? snap.maxIterations ?? 25;
174
+ const newSnap = engine.startGoal(parsed.text, { maxIterations }, sid);
175
+
176
+ // Если команда вызвана в контексте агента, отправляем цель в очередь модели
177
+ if (agent && typeof agent.followup === 'function') {
178
+ try {
179
+ const userMsg = createGoalUserMessage(formatGoalStartPrompt(parsed.text));
180
+ agent.followup(userMsg);
181
+ } catch (err) {
182
+ console.warn('[dsh-goal] Failed to dispatch goal to agent followup:', err);
183
+ }
184
+ }
185
+
186
+ return {
187
+ kind: 'success',
188
+ text: `🎯 Активирована цель: «${newSnap.title}» (макс. ${newSnap.maxIterations} итераций).\n` +
189
+ `Для паузы: /goal pause | Для сброса: /goal clear`,
190
+ };
191
+ }
192
+
193
+ default:
194
+ return {
195
+ kind: 'error',
196
+ text: `Неизвестная команда. ${USAGE}`,
197
+ };
198
+ }
199
+ }
@@ -42,11 +42,28 @@ export function formatElapsed(totalSeconds) {
42
42
  return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
43
43
  }
44
44
 
45
+ /**
46
+ * Форматирование расчетного оставшегося времени (ETA)
47
+ * @param {number|null} seconds
48
+ * @returns {string|null}
49
+ */
50
+ export function formatETA(seconds) {
51
+ if (seconds == null || isNaN(seconds)) return null;
52
+ const sec = Math.max(0, Math.floor(seconds));
53
+ if (sec < 60) return `~${sec}s`;
54
+ const mins = Math.round(sec / 60);
55
+ if (mins < 60) return `~${mins}m`;
56
+ const hours = Math.floor(mins / 60);
57
+ const remainingMins = mins % 60;
58
+ return remainingMins > 0 ? `~${hours}h ${remainingMins}m` : `~${hours}h`;
59
+ }
60
+
45
61
  export class GoalEngine {
46
62
  constructor(options = {}) {
47
63
  this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
48
64
  this.autoDrive = options.autoDrive ?? true;
49
65
  this.enableSound = options.enableSound ?? true;
66
+ this.showQuickLaunchButton = options.showQuickLaunchButton ?? true;
50
67
  this.maxSessions = options.maxSessions ?? 100;
51
68
  this.goals = new Map();
52
69
  this.listeners = new Set();
@@ -96,6 +113,9 @@ export class GoalEngine {
96
113
  if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
97
114
  dirty = true;
98
115
  }
116
+ if (!goal.tokensUsage) {
117
+ goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
118
+ }
99
119
  this.goals.set(sid, goal);
100
120
  }
101
121
  }
@@ -112,6 +132,9 @@ export class GoalEngine {
112
132
  if (data.logs.length > 100) data.logs = data.logs.slice(-100);
113
133
  dirty = true;
114
134
  }
135
+ if (!data.tokensUsage) {
136
+ data.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
137
+ }
115
138
  this.goals.set('default', data);
116
139
  }
117
140
  if (dirty) {
@@ -163,6 +186,10 @@ export class GoalEngine {
163
186
  sessions: sessionsObj,
164
187
  ...(this.goals.has('default') ? this.goals.get('default') : {}),
165
188
  };
189
+ const dir = path.dirname(this.storagePath);
190
+ if (!fs.existsSync(dir)) {
191
+ fs.mkdirSync(dir, { recursive: true });
192
+ }
166
193
  const tmp = `${this.storagePath}.tmp.${Date.now()}`;
167
194
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
168
195
  fs.renameSync(tmp, this.storagePath);
@@ -247,6 +274,9 @@ export class GoalEngine {
247
274
  if (typeof config.enableSound === 'boolean') {
248
275
  this.enableSound = config.enableSound;
249
276
  }
277
+ if (typeof config.showQuickLaunchButton === 'boolean') {
278
+ this.showQuickLaunchButton = config.showQuickLaunchButton;
279
+ }
250
280
  this.emit();
251
281
  }
252
282
 
@@ -308,6 +338,7 @@ export class GoalEngine {
308
338
  iterationsCount: 0,
309
339
  maxIterations: options.maxIterations ?? this.defaultMaxIterations,
310
340
  milestones: [],
341
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
311
342
  logs: [
312
343
  {
313
344
  timestamp: now,
@@ -557,6 +588,57 @@ export class GoalEngine {
557
588
  return Math.floor(elapsedMs / 1000);
558
589
  }
559
590
 
591
+ /**
592
+ * Накопление статистики использования токенов сессии
593
+ * @param {Object} usage
594
+ * @param {string} [sessionId='default']
595
+ */
596
+ addTokenUsage(usage, sessionId = 'default') {
597
+ if (!usage || typeof usage !== 'object') return;
598
+ const sid = sessionId || 'default';
599
+ const goal = this.goals.get(sid);
600
+ if (!goal) return;
601
+
602
+ if (!goal.tokensUsage) {
603
+ goal.tokensUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
604
+ }
605
+
606
+ const prompt = Number(usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
607
+ const completion = Number(usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
608
+ const total = Number(usage.totalTokens ?? usage.total_tokens ?? (prompt + completion)) || (prompt + completion);
609
+
610
+ goal.tokensUsage.promptTokens += prompt;
611
+ goal.tokensUsage.completionTokens += completion;
612
+ goal.tokensUsage.totalTokens += total;
613
+
614
+ this.emit(sid);
615
+ }
616
+
617
+ /**
618
+ * Интеллектуальный расчет прогноза оставшегося времени (ETA)
619
+ * на основе средней скорости выполнения завершенных вех
620
+ * @param {string} [sessionId='default']
621
+ * @returns {number|null}
622
+ */
623
+ getEstimatedRemainingSeconds(sessionId = 'default') {
624
+ const sid = sessionId || 'default';
625
+ const goal = this.goals.get(sid);
626
+ if (!goal || goal.state !== GoalState.RUNNING) return null;
627
+
628
+ const total = goal.milestones.length;
629
+ if (total === 0) return null;
630
+
631
+ const completedCount = goal.milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
632
+ if (completedCount === 0 || completedCount >= total) return null;
633
+
634
+ const elapsed = this.getElapsedSeconds(sid);
635
+ if (elapsed <= 0) return null;
636
+
637
+ const avgSecPerMilestone = elapsed / completedCount;
638
+ const remainingCount = total - completedCount;
639
+ return Math.max(1, Math.round(avgSecPerMilestone * remainingCount));
640
+ }
641
+
560
642
  /**
561
643
  * Снимок состояния для передачи клиенту / API
562
644
  */
@@ -575,12 +657,16 @@ export class GoalEngine {
575
657
  completedAt: null,
576
658
  elapsedSeconds: 0,
577
659
  formattedElapsed: '0s',
660
+ estimatedRemainingSeconds: null,
661
+ formattedETA: null,
662
+ tokensUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
578
663
  milestones: [],
579
664
  progressPercent: 0,
580
665
  iterationsCount: 0,
581
666
  maxIterations: this.defaultMaxIterations,
582
667
  autoDrive: this.autoDrive,
583
668
  enableSound: this.enableSound,
669
+ showQuickLaunchButton: this.showQuickLaunchButton,
584
670
  };
585
671
  }
586
672
 
@@ -588,6 +674,7 @@ export class GoalEngine {
588
674
  const milestones = goal.milestones;
589
675
  const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
590
676
  const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
677
+ const estSec = this.getEstimatedRemainingSeconds(sid);
591
678
 
592
679
  return {
593
680
  sessionId: sid,
@@ -602,6 +689,9 @@ export class GoalEngine {
602
689
  completedAt: goal.completedAt,
603
690
  elapsedSeconds: elapsed,
604
691
  formattedElapsed: formatElapsed(elapsed),
692
+ estimatedRemainingSeconds: estSec,
693
+ formattedETA: formatETA(estSec),
694
+ tokensUsage: goal.tokensUsage || { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
605
695
  iterationsCount: goal.iterationsCount,
606
696
  maxIterations: goal.maxIterations,
607
697
  milestones,
@@ -610,6 +700,7 @@ export class GoalEngine {
610
700
  resultSummary: goal.resultSummary,
611
701
  autoDrive: this.autoDrive,
612
702
  enableSound: this.enableSound,
703
+ showQuickLaunchButton: this.showQuickLaunchButton,
613
704
  };
614
705
  }
615
706
 
@@ -629,9 +720,11 @@ export class GoalEngine {
629
720
  ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
630
721
  : ' (План работ ещё не сформирован — немедленно вызови goal_set_milestones со списком шагов!)';
631
722
 
723
+ const etaText = snapshot.formattedETA ? ` (ETA: ${snapshot.formattedETA})` : '';
724
+
632
725
  return `\n\n[DSH GOAL MODE ACTIVE]
633
726
  Цель: "${snapshot.title}"
634
- Время работы: ${snapshot.formattedElapsed} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
727
+ Время работы: ${snapshot.formattedElapsed}${etaText} | Итерация: ${snapshot.iterationsCount}/${snapshot.maxIterations}
635
728
  План работ:
636
729
  ${milestonesText}
637
730
 
package/lib/index.js CHANGED
@@ -42,6 +42,7 @@ export const Config = z.object({
42
42
  maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
43
43
  autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
44
44
  enableSound: z.boolean().default(true).description('Play synthesized audio chime on goal completion or failure'),
45
+ showQuickLaunchButton: z.boolean().default(true).description('Show quick launch goal button above composer dock'),
45
46
  storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
46
47
  });
47
48
 
@@ -59,6 +60,7 @@ export function apply(ctx, config = {}) {
59
60
  maxIterations: config?.maxIterations ?? 25,
60
61
  autoDrive: config?.autoDrive ?? true,
61
62
  enableSound: config?.enableSound ?? true,
63
+ showQuickLaunchButton: config?.showQuickLaunchButton ?? true,
62
64
  storagePath: config?.storagePath,
63
65
  };
64
66
 
@@ -71,6 +73,7 @@ export function apply(ctx, config = {}) {
71
73
  defaultMaxIterations: currentSettings.maxIterations,
72
74
  autoDrive: currentSettings.autoDrive,
73
75
  enableSound: currentSettings.enableSound,
76
+ showQuickLaunchButton: currentSettings.showQuickLaunchButton,
74
77
  storagePath,
75
78
  });
76
79
 
@@ -215,6 +218,7 @@ export function apply(ctx, config = {}) {
215
218
  maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
216
219
  autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
217
220
  enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
221
+ showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
218
222
  storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
219
223
  };
220
224
  }
@@ -226,6 +230,7 @@ export function apply(ctx, config = {}) {
226
230
  maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
227
231
  autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
228
232
  enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
233
+ showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
229
234
  storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
230
235
  };
231
236
  }
@@ -240,6 +245,7 @@ export function apply(ctx, config = {}) {
240
245
  defaultMaxIterations: live.maxIterations,
241
246
  autoDrive: live.autoDrive,
242
247
  enableSound: live.enableSound,
248
+ showQuickLaunchButton: live.showQuickLaunchButton,
243
249
  });
244
250
  };
245
251
 
@@ -526,12 +532,19 @@ export function apply(ctx, config = {}) {
526
532
 
527
533
  let result = null;
528
534
  switch (action) {
529
- case 'start':
530
- result = engine.startGoal(title || 'Новая цель', {
531
- description,
535
+ case 'start': {
536
+ const cleanTitle = typeof title === 'string' ? title.trim() : '';
537
+ if (!cleanTitle) {
538
+ res.statusCode = 400;
539
+ return res.end(JSON.stringify({ error: 'Goal title cannot be empty' }));
540
+ }
541
+ result = engine.startGoal(cleanTitle, {
542
+ description: typeof description === 'string' ? description.trim() : '',
532
543
  maxIterations: getConfig().maxIterations,
533
544
  }, sid);
545
+ resumeActiveAgent(`🎯 Цель установлена: "${cleanTitle}". Немедленно сформируй план работ (3-7 конкретных шагов) через инструмент goal_set_milestones и начни его выполнение.`, sid);
534
546
  break;
547
+ }
535
548
  case 'pause':
536
549
  result = engine.pause(reason || 'Пауза по кнопке интерфейса', sid);
537
550
  stopRunningAgents(sid);
@@ -549,14 +562,24 @@ export function apply(ctx, config = {}) {
549
562
  stopRunningAgents(sid);
550
563
  sessionAgents.delete(sid);
551
564
  break;
552
- case 'update_milestone':
565
+ case 'update_milestone': {
553
566
  if (!milestoneId || !status) {
554
567
  res.statusCode = 400;
555
568
  return res.end(JSON.stringify({ error: 'milestoneId and status are required' }));
556
569
  }
557
- engine.updateMilestone(milestoneId, status, notes, sid);
570
+ const validStatuses = Object.values(MilestoneStatus);
571
+ if (!validStatuses.includes(status)) {
572
+ res.statusCode = 400;
573
+ return res.end(JSON.stringify({ error: `Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}` }));
574
+ }
575
+ const ok = engine.updateMilestone(milestoneId, status, notes, sid);
576
+ if (!ok) {
577
+ res.statusCode = 404;
578
+ return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
579
+ }
558
580
  result = engine.getSnapshot(sid);
559
581
  break;
582
+ }
560
583
  default:
561
584
  res.statusCode = 400;
562
585
  return res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
@@ -599,6 +622,13 @@ export function apply(ctx, config = {}) {
599
622
  if (!liveConfig.autoDrive) return; // Учитываем настройку autoDrive в рантайме
600
623
 
601
624
  const sid = sessionIdOf(turn, 'default');
625
+
626
+ // Накопление токенов по завершении хода
627
+ const usage = turn?.usage || turn?.meta?.usage || turn?.response?.usage || turn?.turn?.usage;
628
+ if (usage) {
629
+ engine.addTokenUsage(usage, sid);
630
+ }
631
+
602
632
  const snap = engine.getSnapshot(sid);
603
633
  if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
604
634
  // Проверяем причину завершения хода
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",