claude-controller 0.2.0 → 0.3.0

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.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +5 -1
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
@@ -1,5 +1,35 @@
1
1
  /* ── Settings & Overlays ── */
2
2
 
3
+ /* ── Theme Toggle FAB ── */
4
+ .theme-fab {
5
+ position: fixed;
6
+ bottom: 80px;
7
+ right: 24px;
8
+ z-index: 300;
9
+ width: 36px;
10
+ height: 36px;
11
+ border-radius: 50%;
12
+ background: var(--surface);
13
+ border: 1px solid var(--border);
14
+ color: var(--text-secondary);
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ cursor: pointer;
19
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
20
+ transition: all var(--transition);
21
+ }
22
+ .theme-fab:hover {
23
+ background: var(--surface-hover);
24
+ color: var(--accent);
25
+ border-color: var(--accent);
26
+ }
27
+ /* dark mode: show sun icon, hide moon */
28
+ .theme-icon-light { display: none; }
29
+ .theme-icon-dark { display: block; }
30
+ [data-theme="light"] .theme-icon-light { display: block; }
31
+ [data-theme="light"] .theme-icon-dark { display: none; }
32
+
3
33
  /* ── Settings Panel ── */
4
34
  .settings-fab {
5
35
  position: fixed;
@@ -210,11 +240,11 @@
210
240
 
211
241
  .job-preview {
212
242
  padding: 6px 14px;
213
- background: var(--bg);
243
+ background: var(--stream-bg, #080a0e);
214
244
  font-family: var(--font-mono);
215
245
  font-size: 0.78rem;
216
246
  line-height: 1.7;
217
- color: var(--text-muted);
247
+ color: var(--text);
218
248
  max-height: calc(0.78rem * 1.7 * 2 + 6px + 6px);
219
249
  overflow: hidden;
220
250
  box-sizing: content-box;
@@ -226,6 +256,7 @@
226
256
  .preview-line {
227
257
  padding: 2px 0;
228
258
  word-break: break-word;
259
+ color: var(--text);
229
260
  }
230
261
  .preview-line + .preview-line {
231
262
  margin-top: 1px;
@@ -255,6 +286,6 @@
255
286
  font-weight: 700;
256
287
  }
257
288
  .preview-text {
258
- color: var(--text-muted);
289
+ color: var(--text);
259
290
  }
260
291
 
@@ -27,11 +27,26 @@ function _populateSettingsUI() {
27
27
  const d = _settingsData;
28
28
  const sel = document.getElementById('cfgLocale');
29
29
  if (sel) sel.value = d.locale || _currentLocale;
30
+
31
+ const whUrl = document.getElementById('cfgWebhookUrl');
32
+ if (whUrl) whUrl.value = d.webhook_url || '';
33
+ const whSecret = document.getElementById('cfgWebhookSecret');
34
+ if (whSecret) whSecret.value = d.webhook_secret || '';
35
+ const whEvents = document.getElementById('cfgWebhookEvents');
36
+ if (whEvents) whEvents.value = d.webhook_events || 'done,failed';
30
37
  }
31
38
 
32
39
  async function saveSettings() {
33
40
  const locale = document.getElementById('cfgLocale').value;
34
- const payload = { locale };
41
+ const webhookUrl = (document.getElementById('cfgWebhookUrl')?.value || '').trim();
42
+ const webhookSecret = (document.getElementById('cfgWebhookSecret')?.value || '').trim();
43
+ const webhookEvents = document.getElementById('cfgWebhookEvents')?.value || 'done,failed';
44
+ const payload = {
45
+ locale,
46
+ webhook_url: webhookUrl,
47
+ webhook_secret: webhookSecret,
48
+ webhook_events: webhookEvents,
49
+ };
35
50
  try {
36
51
  await apiFetch('/api/config', {
37
52
  method: 'POST',
@@ -43,3 +58,24 @@ async function saveSettings() {
43
58
  showToast(t('msg_settings_save_failed') + ': ' + e.message, 'error');
44
59
  }
45
60
  }
61
+
62
+ async function testWebhook() {
63
+ const btn = document.getElementById('btnWebhookTest');
64
+ if (!btn) return;
65
+ const orig = btn.innerHTML;
66
+ btn.disabled = true;
67
+ btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
68
+ try {
69
+ const result = await apiFetch('/api/webhooks/test', { method: 'POST' });
70
+ if (result.delivered) {
71
+ showToast(`웹훅 전송 성공 (HTTP ${result.status_code})`);
72
+ } else {
73
+ showToast(`웹훅 전송 실패: ${result.error}`, 'error');
74
+ }
75
+ } catch (e) {
76
+ showToast(`웹훅 테스트 실패: ${e.message}`, 'error');
77
+ } finally {
78
+ btn.disabled = false;
79
+ btn.innerHTML = orig;
80
+ }
81
+ }
@@ -31,15 +31,30 @@ function updateJobPreview(jobId) {
31
31
  }
32
32
  }
33
33
 
34
- function initStream(jobId) {
35
- if (streamState[jobId] && streamState[jobId].timer) return;
34
+ function initStream(jobId, jobData) {
36
35
  if (streamState[jobId] && streamState[jobId]._bulkLoading) return;
37
36
 
38
37
  if (!streamState[jobId]) {
39
- streamState[jobId] = { offset: 0, timer: null, done: false, jobData: null, events: [], renderedCount: 0 };
38
+ streamState[jobId] = { offset: 0, timer: null, done: false, jobData: jobData || null, events: [], renderedCount: 0, _initTime: Date.now(), _lastEventTime: Date.now() };
39
+ } else if (jobData) {
40
+ streamState[jobId].jobData = jobData;
40
41
  }
41
42
 
42
43
  const state = streamState[jobId];
44
+
45
+ // expand 패널이 새로 생겼는데 아직 placeholder 상태면 캐시된 이벤트를 즉시 렌더링
46
+ const container = document.getElementById(`streamContent-${jobId}`);
47
+ if (container && state.events.length > 0) {
48
+ const isEmpty = container.querySelector('.stream-empty') || container.children.length === 0;
49
+ if (isEmpty) {
50
+ state.renderedCount = 0;
51
+ renderStreamEvents(jobId);
52
+ }
53
+ }
54
+
55
+ // SSE 또는 폴링이 이미 진행 중이면 중복 시작하지 않음
56
+ if (state.timer || state._eventSource) return;
57
+
43
58
  if (state.done && state.events.length > 0) {
44
59
  renderStreamEvents(jobId);
45
60
  return;
@@ -51,8 +66,67 @@ function initStream(jobId) {
51
66
  return;
52
67
  }
53
68
 
54
- pollStream(jobId);
55
- state.timer = setInterval(() => pollStream(jobId), 500);
69
+ // SSE 실시간 스트림 시작 (실패 시 자동 폴링 폴백)
70
+ startSSEStream(jobId);
71
+ }
72
+
73
+ function startSSEStream(jobId) {
74
+ const state = streamState[jobId];
75
+ if (!state) return;
76
+
77
+ let url = `${API}/api/jobs/${encodeURIComponent(jobId)}/stream`;
78
+ if (AUTH_TOKEN) url += `?token=${encodeURIComponent(AUTH_TOKEN)}`;
79
+
80
+ const es = new EventSource(url);
81
+ state._eventSource = es;
82
+
83
+ es.onmessage = function(e) {
84
+ try {
85
+ const evt = JSON.parse(e.data);
86
+ state.events.push(evt);
87
+ state._lastEventTime = Date.now();
88
+ renderStreamEvents(jobId);
89
+ updateJobPreview(jobId);
90
+ } catch { /* parse error */ }
91
+ };
92
+
93
+ es.addEventListener('done', function(e) {
94
+ es.close();
95
+ state._eventSource = null;
96
+ state.done = true;
97
+
98
+ let finalStatus = 'done';
99
+ try {
100
+ const data = JSON.parse(e.data);
101
+ finalStatus = data.status || 'done';
102
+ } catch {}
103
+
104
+ const lastResult = state.events.filter(ev => ev.type === 'result').pop();
105
+ if (lastResult && lastResult.is_error) finalStatus = 'failed';
106
+ if (state.jobData) state.jobData.status = finalStatus;
107
+
108
+ renderStreamDone(jobId);
109
+ updateJobRowStatus(jobId, finalStatus);
110
+ notifyJobDone(jobId, finalStatus, state.jobData ? state.jobData.prompt : '');
111
+
112
+ const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
113
+ if (pvRow) pvRow.remove();
114
+
115
+ fetchJobs();
116
+ });
117
+
118
+ es.onerror = function() {
119
+ if (state.done) return;
120
+
121
+ // SSE 실패 → 폴링 폴백
122
+ es.close();
123
+ state._eventSource = null;
124
+
125
+ if (!state.timer) {
126
+ pollStream(jobId);
127
+ state.timer = setInterval(() => pollStream(jobId), 500);
128
+ }
129
+ };
56
130
  }
57
131
 
58
132
  async function loadStreamBulk(jobId) {
@@ -69,13 +143,28 @@ async function loadStreamBulk(jobId) {
69
143
  }
70
144
  if (data.done || !data.events || data.events.length === 0) {
71
145
  state.done = true;
146
+ const lastResult = state.events.filter(e => e.type === 'result').pop();
147
+ const finalStatus = (lastResult && lastResult.is_error) ? 'failed'
148
+ : (state.jobData ? state.jobData.status : 'done');
149
+ if (state.jobData) state.jobData.status = finalStatus;
72
150
  renderStreamDone(jobId);
73
- updateJobRowStatus(jobId, state.jobData ? state.jobData.status : 'done');
151
+ updateJobRowStatus(jobId, finalStatus);
74
152
  const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
75
153
  if (pvRow) pvRow.remove();
76
154
  }
77
- } catch {
78
- // 네트워크 오류 시 재시도 가능
155
+ } catch (err) {
156
+ const container = document.getElementById(`streamContent-${jobId}`);
157
+ if (container) {
158
+ const retryId = `retryBulk-${jobId}`;
159
+ container.innerHTML = `<div class="stream-error-state">
160
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
161
+ <span>${escapeHtml(t('stream_load_failed'))}</span>
162
+ <button class="btn btn-sm" id="${retryId}" onclick="event.stopPropagation(); loadStreamBulk('${escapeHtml(jobId)}')">
163
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
164
+ ${escapeHtml(t('stream_retry'))}
165
+ </button>
166
+ </div>`;
167
+ }
79
168
  } finally {
80
169
  state._bulkLoading = false;
81
170
  }
@@ -84,6 +173,10 @@ async function loadStreamBulk(jobId) {
84
173
  function stopStream(jobId) {
85
174
  const state = streamState[jobId];
86
175
  if (!state) return;
176
+ if (state._eventSource) {
177
+ state._eventSource.close();
178
+ state._eventSource = null;
179
+ }
87
180
  if (state.timer) {
88
181
  clearInterval(state.timer);
89
182
  state.timer = null;
@@ -103,9 +196,17 @@ async function pollStream(jobId) {
103
196
  const newOffset = data.offset !== undefined ? data.offset : state.offset + events.length;
104
197
  const done = !!data.done;
105
198
 
199
+ // 성공 시 실패 카운터 초기화 + 백오프 복원
200
+ if (state._pollFails > 0) {
201
+ state._pollFails = 0;
202
+ _setPollInterval(jobId, 500);
203
+ _clearPollWarning(jobId);
204
+ }
205
+
106
206
  if (events.length > 0) {
107
207
  state.events = state.events.concat(events);
108
208
  state.offset = newOffset;
209
+ state._lastEventTime = Date.now();
109
210
  renderStreamEvents(jobId);
110
211
  updateJobPreview(jobId);
111
212
  }
@@ -113,14 +214,95 @@ async function pollStream(jobId) {
113
214
  if (done) {
114
215
  state.done = true;
115
216
  stopStream(jobId);
217
+
218
+ // result 이벤트의 is_error 로 실제 최종 상태 결정
219
+ const lastResult = state.events.filter(e => e.type === 'result').pop();
220
+ const finalStatus = lastResult && lastResult.is_error ? 'failed' : 'done';
221
+ if (state.jobData) state.jobData.status = finalStatus;
222
+
116
223
  renderStreamDone(jobId);
117
- updateJobRowStatus(jobId, state.jobData ? state.jobData.status : 'done');
224
+ updateJobRowStatus(jobId, finalStatus);
225
+ notifyJobDone(jobId, finalStatus, state.jobData ? state.jobData.prompt : '');
118
226
  const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
119
227
  if (pvRow) pvRow.remove();
228
+
229
+ // 즉시 전체 행 동기화 (액션 버튼, 필터 등)
230
+ fetchJobs();
120
231
  }
121
232
  } catch {
122
- // Network errorkeep retrying
233
+ // 네트워크 에러지수 백오프 + 실패 피드백
234
+ state._pollFails = (state._pollFails || 0) + 1;
235
+ const fails = state._pollFails;
236
+
237
+ if (fails >= 20) {
238
+ // 20회 연속 실패 (~2분) → 폴링 중단, 재시도 버튼 표시
239
+ stopStream(jobId);
240
+ _showPollRetry(jobId);
241
+ } else if (fails >= 5) {
242
+ // 5회+ 실패 → 경고 표시 + 백오프 (1s → 2s → 4s → 최대 10s)
243
+ const interval = Math.min(500 * Math.pow(2, fails - 4), 10000);
244
+ _setPollInterval(jobId, interval);
245
+ _showPollWarning(jobId);
246
+ }
247
+ }
248
+ }
249
+
250
+ /** 폴링 간격을 동적으로 변경한다. */
251
+ function _setPollInterval(jobId, ms) {
252
+ const state = streamState[jobId];
253
+ if (!state || !state.timer) return;
254
+ clearInterval(state.timer);
255
+ state.timer = setInterval(() => pollStream(jobId), ms);
256
+ }
257
+
258
+ /** 연결 불안정 경고를 스트림 패널에 표시한다. */
259
+ function _showPollWarning(jobId) {
260
+ const panel = document.getElementById(`streamPanel-${jobId}`);
261
+ if (!panel || panel.querySelector('.stream-poll-warning')) return;
262
+ const warn = document.createElement('div');
263
+ warn.className = 'stream-poll-warning';
264
+ warn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> ${escapeHtml(t('conn_lost'))}`;
265
+ panel.prepend(warn);
266
+ }
267
+
268
+ function _clearPollWarning(jobId) {
269
+ const panel = document.getElementById(`streamPanel-${jobId}`);
270
+ if (!panel) return;
271
+ const warn = panel.querySelector('.stream-poll-warning');
272
+ if (warn) warn.remove();
273
+ }
274
+
275
+ /** 폴링이 포기된 후 수동 재시도 버튼을 표시한다. */
276
+ function _showPollRetry(jobId) {
277
+ _clearPollWarning(jobId);
278
+ const container = document.getElementById(`streamContent-${jobId}`);
279
+ if (!container) return;
280
+ // 이미 표시된 경우 중복 방지
281
+ if (container.querySelector('.stream-poll-retry')) return;
282
+ const retryDiv = document.createElement('div');
283
+ retryDiv.className = 'stream-error-state stream-poll-retry';
284
+ retryDiv.innerHTML = `
285
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
286
+ <span>${escapeHtml(t('conn_lost'))}</span>
287
+ <button class="btn btn-sm" onclick="event.stopPropagation(); retryPollStream('${escapeHtml(jobId)}')">
288
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
289
+ ${escapeHtml(t('stream_reconnect'))}
290
+ </button>`;
291
+ container.appendChild(retryDiv);
292
+ }
293
+
294
+ function retryPollStream(jobId) {
295
+ const state = streamState[jobId];
296
+ if (!state) return;
297
+ state._pollFails = 0;
298
+ // 재시도 UI 제거
299
+ const container = document.getElementById(`streamContent-${jobId}`);
300
+ if (container) {
301
+ const retry = container.querySelector('.stream-poll-retry');
302
+ if (retry) retry.remove();
123
303
  }
304
+ // SSE 우선 시도, 실패 시 자동 폴링 전환
305
+ startSSEStream(jobId);
124
306
  }
125
307
 
126
308
  function renderStreamEvents(jobId) {
@@ -154,8 +336,14 @@ function renderStreamEvents(jobId) {
154
336
  break;
155
337
  case 'result':
156
338
  div.classList.add('stream-event-result');
157
- div.innerHTML = `<span class="stream-result-icon">✓</span>
158
- <span class="stream-result-text">${escapeHtml(typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || ''))}</span>`;
339
+ if (evt.is_error && evt.user_error) {
340
+ div.classList.add('stream-event-result-error');
341
+ div.innerHTML = `<span class="stream-result-icon">✗</span>
342
+ <span class="stream-result-text">${escapeHtml(evt.user_error.summary)}</span>`;
343
+ } else {
344
+ div.innerHTML = `<span class="stream-result-icon">✓</span>
345
+ <span class="stream-result-text">${escapeHtml(typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || ''))}</span>`;
346
+ }
159
347
  if (evt.session_id) {
160
348
  const panel = document.getElementById(`streamPanel-${jobId}`);
161
349
  if (panel) panel.dataset.sessionId = evt.session_id;
@@ -167,7 +355,7 @@ function renderStreamEvents(jobId) {
167
355
  cells[4].className = 'job-session clickable';
168
356
  cells[4].title = evt.session_id;
169
357
  const evtCwd = panel ? (panel.dataset.cwd || '') : '';
170
- cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(evt.session_id)}', '', '${escapeHtml(evtCwd)}')`);
358
+ cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeJsStr(evt.session_id)}', '', '${escapeJsStr(evtCwd)}')`);
171
359
  }
172
360
  }
173
361
  }
@@ -202,12 +390,47 @@ function renderStreamDone(jobId) {
202
390
  const status = state && state.jobData ? state.jobData.status : 'done';
203
391
  const isFailed = status === 'failed';
204
392
 
393
+ // 이벤트가 0개면 "불러오는 중" placeholder를 "출력 없음"으로 교체
394
+ if (state && state.events.length === 0) {
395
+ const container = document.getElementById(`streamContent-${jobId}`);
396
+ if (container && (container.querySelector('.stream-empty') || container.children.length === 0)) {
397
+ container.innerHTML = `<div class="stream-no-output">${t('stream_no_output')}</div>`;
398
+ }
399
+ }
400
+
401
+ // 소요시간 추출
402
+ const lastResult = state ? state.events.filter(e => e.type === 'result').pop() : null;
403
+ let bannerDetails = '';
404
+ if (lastResult) {
405
+ const info = formatDuration(lastResult.duration_ms);
406
+ if (info) bannerDetails = ` — ${info}`;
407
+ }
408
+
205
409
  let banner = panel.querySelector('.stream-done-banner');
206
410
  if (!banner) {
207
411
  banner = document.createElement('div');
208
412
  banner.className = `stream-done-banner${isFailed ? ' failed' : ''}`;
209
- banner.textContent = isFailed ? '✗ 작업 실패' : ' 작업 완료';
413
+ banner.textContent = (isFailed ? `✗ ${t('stream_job_failed')}` : `✓ ${t('stream_job_done')}`) + bannerDetails;
210
414
  panel.appendChild(banner);
415
+ } else if (bannerDetails && !banner.textContent.includes('—')) {
416
+ banner.textContent = (isFailed ? `✗ ${t('stream_job_failed')}` : `✓ ${t('stream_job_done')}`) + bannerDetails;
417
+ }
418
+
419
+ // 실패 시 사용자 친화적 에러 카드 표시
420
+ if (isFailed && lastResult && !panel.querySelector('.user-error-card')) {
421
+ const ue = lastResult.user_error;
422
+ if (ue) {
423
+ const card = document.createElement('div');
424
+ card.className = 'user-error-card';
425
+ const stepsHtml = (ue.next_steps || []).map(s => `<li>${escapeHtml(s)}</li>`).join('');
426
+ const rawText = typeof lastResult.result === 'string' ? lastResult.result : '';
427
+ const detailsHtml = rawText ? `<details class="user-error-details"><summary>${escapeHtml(t('err_show_log'))}</summary><pre class="user-error-raw">${escapeHtml(rawText.slice(0, 2000))}</pre></details>` : '';
428
+ card.innerHTML = `<div class="user-error-summary">${escapeHtml(ue.summary)}</div>
429
+ <div class="user-error-cause">${escapeHtml(ue.cause)}</div>
430
+ ${stepsHtml ? `<ul class="user-error-steps">${stepsHtml}</ul>` : ''}
431
+ ${detailsHtml}`;
432
+ banner.insertAdjacentElement('afterend', card);
433
+ }
211
434
  }
212
435
 
213
436
  let actions = panel.querySelector('.stream-actions');
@@ -217,11 +440,11 @@ function renderStreamDone(jobId) {
217
440
  actions.innerHTML = `
218
441
  <button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(jobId)}')">
219
442
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
220
- 전체 복사
443
+ ${escapeHtml(t('stream_copy_all'))}
221
444
  </button>
222
445
  <button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(jobId)}')">
223
446
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
224
- 작업 제거
447
+ ${escapeHtml(t('stream_delete_job'))}
225
448
  </button>`;
226
449
  panel.appendChild(actions);
227
450
  }
@@ -231,14 +454,14 @@ function renderStreamDone(jobId) {
231
454
  const followup = document.createElement('div');
232
455
  followup.className = 'stream-followup';
233
456
  followup.innerHTML = `
234
- <span class="stream-followup-label">이어서</span>
457
+ <span class="stream-followup-label">${escapeHtml(t('stream_followup_label'))}</span>
235
458
  <div class="followup-input-wrap">
236
459
  <input type="text" class="followup-input" id="followupInput-${escapeHtml(jobId)}"
237
- placeholder="이 세션에 이어서 실행할 명령... (파일/이미지 붙여넣기 가능)"
460
+ placeholder="${escapeHtml(t('stream_followup_placeholder'))}"
238
461
  onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(jobId)}')}"
239
462
  onclick="event.stopPropagation()">
240
463
  <button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(jobId)}')" style="white-space:nowrap;">
241
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> 전송
464
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> ${escapeHtml(t('send'))}
242
465
  </button>
243
466
  </div>
244
467
  <div class="followup-previews" id="followupPreviews-${escapeHtml(jobId)}"></div>`;
@@ -5,13 +5,19 @@
5
5
  function showToast(message, type = 'success') {
6
6
  const container = document.getElementById('toastContainer');
7
7
  const toast = document.createElement('div');
8
+ const duration = type === 'error' ? 6000 : 3000;
8
9
  toast.className = `toast ${type}`;
10
+ toast.style.setProperty('--toast-duration', `${duration}ms`);
9
11
  const icon = type === 'success'
10
12
  ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
11
13
  : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
12
- toast.innerHTML = `${icon} ${escapeHtml(message)}`;
14
+ toast.innerHTML = `${icon} <span class="toast-msg">${escapeHtml(message)}</span><span class="toast-close">&times;</span>`;
15
+ toast.addEventListener('click', () => {
16
+ toast.style.animation = 'toastOut 0.2s ease forwards';
17
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, 200);
18
+ });
13
19
  container.appendChild(toast);
14
- setTimeout(() => { if (toast.parentNode) toast.remove(); }, 3000);
20
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, duration);
15
21
  }
16
22
 
17
23
  function escapeHtml(str) {
@@ -21,6 +27,12 @@ function escapeHtml(str) {
21
27
  return d.innerHTML;
22
28
  }
23
29
 
30
+ /** JS 문자열 이스케이프 — onclick 핸들러 내 싱글쿼트 문자열에서 사용 */
31
+ function escapeJsStr(str) {
32
+ if (!str) return '';
33
+ return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
34
+ }
35
+
24
36
  function truncate(str, len = 60) {
25
37
  if (!str) return '-';
26
38
  return str.length > len ? str.slice(0, len) + '...' : str;
@@ -77,3 +89,43 @@ function getFileExt(filename) {
77
89
  const dot = filename.lastIndexOf('.');
78
90
  return dot >= 0 ? filename.slice(dot + 1).toUpperCase() : '?';
79
91
  }
92
+
93
+ /* ── Desktop Notification ── */
94
+ function requestNotificationPermission() {
95
+ if ('Notification' in window && Notification.permission === 'default') {
96
+ Notification.requestPermission();
97
+ }
98
+ }
99
+
100
+ function notifyJobDone(jobId, status, prompt) {
101
+ if (!('Notification' in window) || Notification.permission !== 'granted') return;
102
+ if (document.hasFocus()) return;
103
+ const title = status === 'done' ? `Job #${jobId} 완료` : `Job #${jobId} 실패`;
104
+ const body = truncate(prompt || '', 80);
105
+ const icon = status === 'done'
106
+ ? 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">%E2%9C%85</text></svg>'
107
+ : 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">%E2%9D%8C</text></svg>';
108
+ try {
109
+ const n = new Notification(title, { body, icon, tag: `job-${jobId}` });
110
+ n.onclick = () => { window.focus(); toggleJobExpand(String(jobId)); n.close(); };
111
+ setTimeout(() => n.close(), 8000);
112
+ } catch { /* silent */ }
113
+ }
114
+
115
+ /* ── Duration formatting ── */
116
+ function formatDuration(durationMs) {
117
+ if (durationMs == null) return '';
118
+ const sec = durationMs / 1000;
119
+ return sec < 60 ? `${sec.toFixed(1)}s` : `${Math.floor(sec / 60)}m ${Math.round(sec % 60)}s`;
120
+ }
121
+
122
+ /* ── Theme ── */
123
+ function applyTheme(theme) {
124
+ document.documentElement.setAttribute('data-theme', theme);
125
+ localStorage.setItem('theme', theme);
126
+ }
127
+
128
+ function toggleTheme() {
129
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
130
+ applyTheme(current === 'dark' ? 'light' : 'dark');
131
+ }