claude-controller 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-controller",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Claude Code headless daemon controller — FIFO-based async task dispatch, Git Worktree isolation, auto-checkpointing, and a web dashboard",
5
5
  "license": "MIT",
6
6
  "author": "choiwon",
@@ -56,6 +56,10 @@
56
56
  },
57
57
  "repository": {
58
58
  "type": "git",
59
- "url": ""
60
- }
59
+ "url": "git+https://github.com/k984530/controller.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/k984530/controller/issues"
63
+ },
64
+ "homepage": "https://github.com/k984530/controller#readme"
61
65
  }
package/web/static/app.js CHANGED
@@ -132,7 +132,8 @@ function _renderSessionItem(s) {
132
132
  const costLabel = s.cost_usd != null ? '$' + Number(s.cost_usd).toFixed(4) : '';
133
133
  const timeLabel = s.timestamp ? s.timestamp.replace(/^\d{4}-/, '') : '';
134
134
  return '<div class="session-item" data-sid="' + escapeHtml(s.session_id)
135
- + '" data-prompt="' + escapeHtml(s.prompt || '') + '">'
135
+ + '" data-prompt="' + escapeHtml(s.prompt || '')
136
+ + '" data-cwd="' + escapeHtml(s.cwd || '') + '">'
136
137
  + '<div class="session-item-row">'
137
138
  + '<span class="session-item-status ' + escapeHtml(statusClass) + '"></span>'
138
139
  + '<span class="session-item-id">' + escapeHtml(s.session_id.slice(0, 8)) + '</span>'
@@ -306,11 +307,16 @@ function _toggleProjectFilter() {
306
307
  _filterSessions(searchInput ? searchInput.value : '');
307
308
  }
308
309
 
309
- function _selectSession(sid, prompt) {
310
+ function _selectSession(sid, prompt, cwd) {
310
311
  _contextSessionId = sid;
311
312
  _contextSessionPrompt = prompt;
312
313
  _updateContextUI();
313
314
  _closeSessionPicker();
315
+ // 세션의 cwd로 디렉터리 + 최근 프로젝트 칩 동기화
316
+ if (cwd) {
317
+ addRecentDir(cwd);
318
+ selectRecentDir(cwd);
319
+ }
314
320
  showToast(t('msg_session_select') + ': ' + sid.slice(0, 8) + '...');
315
321
  }
316
322
 
@@ -321,7 +327,7 @@ function _closeSessionPicker() {
321
327
  document.addEventListener('click', function(e) {
322
328
  var item = e.target.closest('.session-item');
323
329
  if (item && item.dataset.sid) {
324
- _selectSession(item.dataset.sid, item.dataset.prompt || '');
330
+ _selectSession(item.dataset.sid, item.dataset.prompt || '', item.dataset.cwd || '');
325
331
  return;
326
332
  }
327
333
  var picker = document.getElementById('sessionPicker');
@@ -533,6 +539,8 @@ async function handleFiles(files) {
533
539
 
534
540
  try {
535
541
  const data = await uploadFile(file);
542
+ // 업로드 중 사용자가 첨부를 제거했거나 폼이 초기화된 경우 무시
543
+ if (!attachments[tempIdx]) continue;
536
544
  attachments[tempIdx].serverPath = data.path;
537
545
  attachments[tempIdx].filename = data.filename || file.name;
538
546
  thumb.classList.remove('uploading');
@@ -541,8 +549,8 @@ async function handleFiles(files) {
541
549
  insertAtCursor(ta, `@image${tempIdx}`);
542
550
  } catch (err) {
543
551
  showToast(`${t('msg_upload_failed')}: ${escapeHtml(file.name)} — ${err.message}`, 'error');
544
- attachments[tempIdx] = null;
545
- thumb.remove();
552
+ if (attachments[tempIdx]) attachments[tempIdx] = null;
553
+ if (thumb.parentNode) thumb.remove();
546
554
  updateAttachBadge();
547
555
  }
548
556
  }
@@ -615,15 +623,16 @@ function statusBadgeHtml(status) {
615
623
  return `<span class="badge ${cls[s] || 'badge-pending'}">${labels[s] || s}</span>`;
616
624
  }
617
625
 
618
- function jobActionsHtml(id, status, sessionId) {
626
+ function jobActionsHtml(id, status, sessionId, cwd) {
619
627
  const isRunning = status === 'running';
628
+ const escapedCwd = escapeHtml(cwd || '');
620
629
  let btns = '';
621
630
  if (!isRunning) {
622
631
  btns += `<button class="btn-retry-job" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')" title="같은 프롬프트로 다시 실행"><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></button>`;
623
632
  }
624
633
  if (sessionId) {
625
634
  btns += `<button class="btn-continue-job" onclick="event.stopPropagation(); openFollowUp('${escapeHtml(id)}')" title="세션 이어서 명령 (resume)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg></button>`;
626
- btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}')" title="이 세션에서 분기 (fork)" style="color:var(--yellow);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v3c0 3.3 2.7 6 6 6h3"/></svg></button>`;
635
+ btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}', '${escapedCwd}')" title="이 세션에서 분기 (fork)" style="color:var(--yellow);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v3c0 3.3 2.7 6 6 6h3"/></svg></button>`;
627
636
  }
628
637
  if (!isRunning) {
629
638
  btns += `<button class="btn-delete-job" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')" title="작업 제거"><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></button>`;
@@ -633,22 +642,32 @@ function jobActionsHtml(id, status, sessionId) {
633
642
  }
634
643
 
635
644
  // 작업 목록에서 직접 fork 세션 선택
636
- function quickForkSession(sessionId) {
645
+ function quickForkSession(sessionId, cwd) {
637
646
  _contextMode = 'fork';
638
647
  _contextSessionId = sessionId;
639
648
  _contextSessionPrompt = null;
640
649
  _updateContextUI();
650
+ // 세션의 cwd로 디렉터리 + 최근 프로젝트 칩 동기화
651
+ if (cwd) {
652
+ addRecentDir(cwd);
653
+ selectRecentDir(cwd);
654
+ }
641
655
  showToast(t('msg_fork_mode') + ' (' + sessionId.slice(0, 8) + '...). ' + t('msg_fork_input'));
642
656
  document.getElementById('promptInput').focus();
643
657
  window.scrollTo({ top: 0, behavior: 'smooth' });
644
658
  }
645
659
 
646
660
  // 작업 목록의 세션 ID 클릭 → resume 모드로 전환
647
- function resumeFromJob(sessionId, promptHint) {
661
+ function resumeFromJob(sessionId, promptHint, cwd) {
648
662
  _contextMode = 'resume';
649
663
  _contextSessionId = sessionId;
650
664
  _contextSessionPrompt = promptHint || null;
651
665
  _updateContextUI();
666
+ // 세션의 cwd로 디렉터리 + 최근 프로젝트 칩 동기화
667
+ if (cwd) {
668
+ addRecentDir(cwd);
669
+ selectRecentDir(cwd);
670
+ }
652
671
  showToast('Resume 모드: ' + sessionId.slice(0, 8) + '... 세션에 이어서 전송합니다.');
653
672
  document.getElementById('promptInput').focus();
654
673
  window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -851,10 +870,10 @@ function renderJobs(jobs) {
851
870
  if (job.session_id) {
852
871
  cells[4].className = 'job-session clickable';
853
872
  cells[4].title = job.session_id;
854
- cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}')`);
873
+ cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(job.cwd || '')}')`);
855
874
  }
856
875
  }
857
- const newActions = jobActionsHtml(id, job.status, job.session_id);
876
+ const newActions = jobActionsHtml(id, job.status, job.session_id, job.cwd);
858
877
  if (cells[6].innerHTML !== newActions) {
859
878
  cells[6].innerHTML = newActions;
860
879
  }
@@ -871,9 +890,9 @@ function renderJobs(jobs) {
871
890
  <td>${statusBadgeHtml(job.status)}</td>
872
891
  <td class="prompt-cell" title="${escapeHtml(job.prompt)}">${renderPromptHtml(job.prompt)}</td>
873
892
  <td class="job-cwd" title="${escapeHtml(job.cwd || '')}">${escapeHtml(formatCwd(job.cwd))}</td>
874
- <td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}')"` : ''}>${job.session_id ? escapeHtml(job.session_id.slice(0, 8)) : (job.status === 'running' ? '<span style="color:var(--text-muted);font-size:0.7rem;">—</span>' : '-')}</td>
893
+ <td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(job.cwd || '')}')"` : ''}>${job.session_id ? escapeHtml(job.session_id.slice(0, 8)) : (job.status === 'running' ? '<span style="color:var(--text-muted);font-size:0.7rem;">—</span>' : '-')}</td>
875
894
  <td class="job-time">${formatTime(job.created || job.created_at)}</td>
876
- <td>${jobActionsHtml(id, job.status, job.session_id)}</td>`;
895
+ <td>${jobActionsHtml(id, job.status, job.session_id, job.cwd)}</td>`;
877
896
  tbody.appendChild(tr);
878
897
  } else {
879
898
  delete existingRows[id];
@@ -1132,7 +1151,8 @@ function renderStreamEvents(jobId) {
1132
1151
  cells[4].textContent = evt.session_id.slice(0, 8);
1133
1152
  cells[4].className = 'job-session clickable';
1134
1153
  cells[4].title = evt.session_id;
1135
- cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(evt.session_id)}', '')`);
1154
+ const evtCwd = panel ? (panel.dataset.cwd || '') : '';
1155
+ cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(evt.session_id)}', '', '${escapeHtml(evtCwd)}')`);
1136
1156
  }
1137
1157
  }
1138
1158
  }
@@ -1390,6 +1410,12 @@ function renderRecentDirs() {
1390
1410
  }
1391
1411
 
1392
1412
  function selectRecentDir(path) {
1413
+ const current = document.getElementById('cwdInput').value;
1414
+ // 같은 칩을 다시 클릭하면 선택 해제
1415
+ if (current === path) {
1416
+ clearDirSelection();
1417
+ return;
1418
+ }
1393
1419
  document.getElementById('cwdInput').value = path;
1394
1420
  updateCwdBadge(path);
1395
1421
  const text = document.getElementById('dirPickerText');
@@ -1453,6 +1479,7 @@ async function browseTo(path) {
1453
1479
  document.getElementById('dirPickerText').textContent = data.current;
1454
1480
  document.getElementById('dirPickerDisplay').classList.add('has-value');
1455
1481
  document.getElementById('dirPickerClear').classList.add('visible');
1482
+ renderRecentDirs();
1456
1483
 
1457
1484
  renderBreadcrumb(data.current, breadcrumb);
1458
1485
 
@@ -1503,6 +1530,7 @@ function selectCurrentDir() {
1503
1530
 
1504
1531
  addRecentDir(dirBrowserCurrentPath);
1505
1532
  closeDirBrowser();
1533
+ renderRecentDirs();
1506
1534
  }
1507
1535
 
1508
1536
  function clearDirSelection() {
@@ -48,8 +48,6 @@ body {
48
48
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
49
49
  ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
50
50
 
51
- /* ── Header ── */
52
-
53
51
  /* ── Buttons ── */
54
52
  button {
55
53
  font-family: var(--font);
@@ -201,6 +199,7 @@ button {
201
199
 
202
200
  .session-picker {
203
201
  display: none;
202
+ flex-direction: column;
204
203
  position: absolute;
205
204
  top: 100%;
206
205
  right: 0;
@@ -213,8 +212,6 @@ button {
213
212
  border-radius: var(--radius-lg);
214
213
  box-shadow: 0 8px 32px rgba(0,0,0,0.4);
215
214
  z-index: 200;
216
- display: none;
217
- flex-direction: column;
218
215
  }
219
216
 
220
217
  .session-picker.open { display: flex; }
@@ -442,16 +439,11 @@ button {
442
439
  /* ── Layout ── */
443
440
  .layout {
444
441
  min-height: 100vh;
445
- width: 100%;
446
- max-width: 100vw;
447
442
  }
448
443
 
449
444
  .main {
450
- flex: 1;
451
445
  padding: 8px 0;
452
446
  overflow-x: hidden;
453
- width: 100%;
454
- max-width: 100vw;
455
447
  }
456
448
 
457
449
  /* ── CWD badge ── */
@@ -1168,12 +1160,6 @@ thead tr { cursor: default; }
1168
1160
  color: var(--yellow);
1169
1161
  }
1170
1162
 
1171
- .prompt-cell {
1172
- overflow: hidden;
1173
- text-overflow: ellipsis;
1174
- white-space: nowrap;
1175
- }
1176
-
1177
1163
  .job-id {
1178
1164
  font-family: var(--font-mono);
1179
1165
  font-size: 0.78rem;
@@ -1184,10 +1170,6 @@ thead tr { cursor: default; }
1184
1170
  font-family: var(--font-mono);
1185
1171
  font-size: 0.75rem;
1186
1172
  color: var(--text-muted);
1187
- max-width: 100%;
1188
- overflow: hidden;
1189
- text-overflow: ellipsis;
1190
- white-space: nowrap;
1191
1173
  }
1192
1174
 
1193
1175
  .job-session {
@@ -1671,7 +1653,6 @@ thead tr { cursor: default; }
1671
1653
  }
1672
1654
 
1673
1655
  @media (max-width: 900px) {
1674
- .main { padding: 8px 0; }
1675
1656
  th, td { padding: 10px 10px; font-size: 0.78rem; }
1676
1657
  }
1677
1658
 
@@ -1679,7 +1660,6 @@ thead tr { cursor: default; }
1679
1660
  .main { padding: 4px 0; }
1680
1661
  .card-body { padding: 14px; }
1681
1662
  th, td { padding: 8px 6px; font-size: 0.74rem; }
1682
- .job-cwd { max-width: 100%; }
1683
1663
  .stream-content { max-height: 300px; }
1684
1664
  }
1685
1665