taskplane 0.1.4 → 0.1.6

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/bin/taskplane.mjs CHANGED
@@ -215,7 +215,7 @@ orchestrator:
215
215
  integration_branch: "${vars.integration_branch}"
216
216
  batch_id_format: "timestamp"
217
217
  spawn_mode: "subprocess"
218
- tmux_prefix: "orch"
218
+ tmux_prefix: "${vars.tmux_prefix}"
219
219
 
220
220
  dependencies:
221
221
  source: "prompt"
@@ -419,12 +419,14 @@ async function cmdInit(args) {
419
419
 
420
420
  function getPresetVars(preset, projectRoot) {
421
421
  const dirName = path.basename(projectRoot);
422
+ const slug = slugify(dirName);
422
423
  const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
423
424
  return {
424
425
  project_name: dirName,
425
426
  integration_branch: "main",
426
427
  max_lanes: 3,
427
- worktree_prefix: `${slugify(dirName)}-wt`,
428
+ worktree_prefix: `${slug}-wt`,
429
+ tmux_prefix: `${slug}-orch`,
428
430
  tasks_root: "taskplane-tasks",
429
431
  default_area: "general",
430
432
  default_prefix: "TP",
@@ -447,11 +449,13 @@ async function getInteractiveVars(projectRoot) {
447
449
  const test_cmd = await ask("Test command (agents run this to verify work — blank to skip)", detected.test || "");
448
450
  const build_cmd = await ask("Build command (agents run this after tests — blank to skip)", detected.build || "");
449
451
 
452
+ const slug = slugify(project_name);
450
453
  return {
451
454
  project_name,
452
455
  integration_branch,
453
456
  max_lanes,
454
- worktree_prefix: `${slugify(project_name)}-wt`,
457
+ worktree_prefix: `${slug}-wt`,
458
+ tmux_prefix: `${slug}-orch`,
455
459
  tasks_root,
456
460
  default_area,
457
461
  default_prefix,
@@ -142,6 +142,11 @@ const $historyBody = $("history-body");
142
142
  let historyList = []; // compact batch summaries
143
143
  let viewingHistoryId = null; // batchId if viewing history, null if live
144
144
 
145
+ // ─── Viewer State ───────────────────────────────────────────────────────────
146
+
147
+ let viewerMode = null; // "conversation" | "status-md" | null
148
+ let viewerTarget = null; // session name (conversation) or taskId (status-md)
149
+
145
150
  // ─── Render: Header ─────────────────────────────────────────────────────────
146
151
 
147
152
  function renderHeader(batch) {
@@ -300,7 +305,8 @@ function renderLanesTasks(batch, tmuxSessions) {
300
305
  html += ` <div class="lane-right">`;
301
306
  html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "tmux alive" : "tmux dead"}"></span>`;
302
307
  // View button: shows conversation stream if available, else tmux pane
303
- html += ` <button class="tmux-view-btn" onclick="viewConversation('${escapeHtml(lane.tmuxSessionName)}')" title="View worker conversation">👁 View</button>`;
308
+ const isViewingConv = viewerMode === 'conversation' && viewerTarget === lane.tmuxSessionName;
309
+ html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(lane.tmuxSessionName)}')" title="View worker conversation">👁 View</button>`;
304
310
  if (alive) {
305
311
  html += ` <span class="tmux-cmd" data-tmux="${escapeHtml(lane.tmuxSessionName)}" onclick="copyTmuxCmd('${escapeHtml(lane.tmuxSessionName)}')" title="Click to copy">${escapeHtml(tmuxCmd)}</span>`;
306
312
  } else {
@@ -387,9 +393,15 @@ function renderLanesTasks(batch, tmuxSessions) {
387
393
  workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">✗ Worker error</span></div>`;
388
394
  }
389
395
 
396
+ const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
397
+ const eyeHtml = task.status !== 'pending'
398
+ ? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
399
+ : '';
400
+
390
401
  html += `
391
402
  <div class="task-row">
392
403
  <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
404
+ <span class="task-actions">${eyeHtml}</span>
393
405
  <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}</span>
394
406
  <span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
395
407
  <span class="task-duration">${dur}</span>
@@ -601,65 +613,236 @@ function connect() {
601
613
  };
602
614
  }
603
615
 
604
- // ─── Terminal Viewer ─────────────────────────────────────────────────────────
616
+ // ─── Viewer Panel (Conversation + STATUS.md) ────────────────────────────────
605
617
 
606
618
  const $terminalPanel = document.getElementById("terminal-panel");
607
619
  const $terminalTitle = document.getElementById("terminal-title");
608
620
  const $terminalBody = document.getElementById("terminal-body");
609
621
  const $terminalClose = document.getElementById("terminal-close");
622
+ const $autoScrollCheckbox = document.getElementById("auto-scroll-checkbox");
623
+ const $autoScrollText = document.getElementById("auto-scroll-text");
624
+
625
+ // Viewer state
626
+ let viewerTimer = null;
627
+ let autoScrollOn = false;
628
+ let isProgrammaticScroll = false;
629
+
630
+ // Conversation append-only state
631
+ let convRenderedLines = 0;
610
632
 
611
- let activeSession = null;
612
- let conversationTimer = null;
633
+ // STATUS.md diff-and-skip state
634
+ let lastStatusMdText = "";
635
+
636
+ // ── Open conversation viewer ────────────────────────────────────────────────
613
637
 
614
638
  function viewConversation(sessionName) {
615
- if (activeSession === sessionName && $terminalPanel.style.display !== "none") return;
639
+ // Toggle off if already viewing this session
640
+ if (viewerMode === 'conversation' && viewerTarget === sessionName && $terminalPanel.style.display !== 'none') {
641
+ closeViewer();
642
+ return;
643
+ }
616
644
 
617
- closeConversation();
645
+ closeViewer();
646
+
647
+ viewerMode = 'conversation';
648
+ viewerTarget = sessionName;
649
+ autoScrollOn = true;
650
+ convRenderedLines = 0;
618
651
 
619
- activeSession = sessionName;
620
652
  $terminalTitle.textContent = `Worker Conversation — ${sessionName}`;
621
- $terminalPanel.style.display = "";
653
+ $autoScrollText.textContent = 'Follow feed';
654
+ $autoScrollCheckbox.checked = true;
655
+ $terminalPanel.style.display = '';
656
+ $terminalBody.innerHTML = '<div class="conv-stream"></div>';
622
657
 
623
- // Initial load + start polling
624
- loadConversation(sessionName);
625
- conversationTimer = setInterval(() => loadConversation(sessionName), 2000);
658
+ pollConversation();
659
+ viewerTimer = setInterval(pollConversation, 2000);
626
660
 
627
- $terminalPanel.scrollIntoView({ behavior: "smooth", block: "nearest" });
661
+ $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
628
662
  }
629
663
 
630
- function loadConversation(sessionName) {
631
- fetch(`/api/conversation/${encodeURIComponent(sessionName)}`)
664
+ function pollConversation() {
665
+ fetch(`/api/conversation/${encodeURIComponent(viewerTarget)}`)
632
666
  .then(r => r.text())
633
- .then(text => renderConversation(text))
667
+ .then(text => {
668
+ if (!text.trim()) {
669
+ if (convRenderedLines === 0) {
670
+ $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
671
+ }
672
+ return;
673
+ }
674
+
675
+ const lines = text.trim().split('\n');
676
+
677
+ // File was reset (new task on same lane) — full re-render
678
+ if (lines.length < convRenderedLines) {
679
+ convRenderedLines = 0;
680
+ const container = $terminalBody.querySelector('.conv-stream');
681
+ if (container) container.innerHTML = '';
682
+ }
683
+
684
+ // Nothing new
685
+ if (lines.length === convRenderedLines) return;
686
+
687
+ // Ensure container exists
688
+ let container = $terminalBody.querySelector('.conv-stream');
689
+ if (!container) {
690
+ $terminalBody.innerHTML = '';
691
+ container = document.createElement('div');
692
+ container.className = 'conv-stream';
693
+ $terminalBody.appendChild(container);
694
+ }
695
+
696
+ // Append only new events
697
+ const newLines = lines.slice(convRenderedLines);
698
+ for (const line of newLines) {
699
+ try {
700
+ const event = JSON.parse(line);
701
+ const html = renderConvEvent(event);
702
+ if (html) container.insertAdjacentHTML('beforeend', html);
703
+ } catch { continue; }
704
+ }
705
+
706
+ convRenderedLines = lines.length;
707
+
708
+ // Auto-scroll to bottom
709
+ if (autoScrollOn) {
710
+ isProgrammaticScroll = true;
711
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
712
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
713
+ }
714
+ })
634
715
  .catch(() => {});
635
716
  }
636
717
 
637
- function renderConversation(jsonlText) {
638
- if (!jsonlText.trim()) {
639
- $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
718
+ // ── Open STATUS.md viewer ───────────────────────────────────────────────────
719
+
720
+ function viewStatusMd(taskId) {
721
+ // Toggle off if already viewing this task
722
+ if (viewerMode === 'status-md' && viewerTarget === taskId && $terminalPanel.style.display !== 'none') {
723
+ closeViewer();
640
724
  return;
641
725
  }
642
726
 
643
- const lines = jsonlText.trim().split("\n");
644
- let html = '<div class="conv-stream">';
727
+ closeViewer();
645
728
 
646
- for (const line of lines) {
647
- try {
648
- const event = JSON.parse(line);
649
- html += renderConvEvent(event);
650
- } catch { continue; }
729
+ viewerMode = 'status-md';
730
+ viewerTarget = taskId;
731
+ autoScrollOn = false;
732
+ lastStatusMdText = '';
733
+
734
+ $terminalTitle.textContent = `STATUS.md — ${taskId}`;
735
+ $autoScrollText.textContent = 'Track progress';
736
+ $autoScrollCheckbox.checked = false;
737
+ $terminalPanel.style.display = '';
738
+ $terminalBody.innerHTML = '<div class="conv-empty">Loading…</div>';
739
+
740
+ pollStatusMd();
741
+ viewerTimer = setInterval(pollStatusMd, 2000);
742
+
743
+ $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
744
+ }
745
+
746
+ function pollStatusMd() {
747
+ fetch(`/api/status-md/${encodeURIComponent(viewerTarget)}`)
748
+ .then(r => {
749
+ if (!r.ok) throw new Error('not found');
750
+ return r.text();
751
+ })
752
+ .then(text => {
753
+ // Diff-and-skip: no change, no DOM update
754
+ if (text === lastStatusMdText) return;
755
+ lastStatusMdText = text;
756
+
757
+ const { html, hasLastChecked } = renderStatusMd(text);
758
+ $terminalBody.innerHTML = html;
759
+
760
+ // Update tracking highlight
761
+ updateTrackingHighlight();
762
+
763
+ // Auto-scroll to last checked item
764
+ if (autoScrollOn && hasLastChecked) {
765
+ scrollToLastChecked();
766
+ }
767
+ })
768
+ .catch(() => {
769
+ if (!lastStatusMdText) {
770
+ $terminalBody.innerHTML = '<div class="conv-empty">STATUS.md not found</div>';
771
+ }
772
+ });
773
+ }
774
+
775
+ // ── STATUS.md renderer ──────────────────────────────────────────────────────
776
+
777
+ function renderStatusMd(markdown) {
778
+ const lines = markdown.split('\n');
779
+ let lastCheckedIdx = -1;
780
+
781
+ // First pass: find last checked item
782
+ for (let i = 0; i < lines.length; i++) {
783
+ if (/^\s*-\s*\[x\]/i.test(lines[i])) lastCheckedIdx = i;
651
784
  }
652
785
 
653
- html += '</div>';
786
+ let html = '<div class="status-md-content">';
787
+
788
+ for (let i = 0; i < lines.length; i++) {
789
+ const line = lines[i];
654
790
 
655
- // Preserve scroll position if already scrolled up
656
- const wasAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 20;
657
- $terminalBody.innerHTML = html;
658
- if (wasAtBottom) {
659
- $terminalBody.scrollTop = $terminalBody.scrollHeight;
791
+ // Headings
792
+ const hMatch = line.match(/^(#{1,6})\s+(.+)/);
793
+ if (hMatch) {
794
+ const lvl = Math.min(hMatch[1].length, 4);
795
+ html += `<div class="status-md-h${lvl}">${renderInlineMd(hMatch[2])}</div>`;
796
+ continue;
797
+ }
798
+
799
+ // Checked checkbox
800
+ if (/^\s*-\s*\[x\]/i.test(line)) {
801
+ const text = line.replace(/^\s*-\s*\[x\]\s*/i, '');
802
+ const isLast = i === lastCheckedIdx;
803
+ const cls = isLast ? 'status-md-check checked last-checked' : 'status-md-check checked';
804
+ const id = isLast ? ' id="last-checked"' : '';
805
+ html += `<div class="${cls}"${id}><span class="check-box">☑</span><span>${renderInlineMd(text)}</span></div>`;
806
+ continue;
807
+ }
808
+
809
+ // Unchecked checkbox
810
+ if (/^\s*-\s*\[\s\]/.test(line)) {
811
+ const text = line.replace(/^\s*-\s*\[\s\]\s*/, '');
812
+ html += `<div class="status-md-check unchecked"><span class="check-box">☐</span><span>${renderInlineMd(text)}</span></div>`;
813
+ continue;
814
+ }
815
+
816
+ // List item
817
+ const liMatch = line.match(/^\s*-\s+(.*)/);
818
+ if (liMatch) {
819
+ html += `<div class="status-md-li">• ${renderInlineMd(liMatch[1])}</div>`;
820
+ continue;
821
+ }
822
+
823
+ // Empty line
824
+ if (!line.trim()) {
825
+ html += '<div class="status-md-spacer"></div>';
826
+ continue;
827
+ }
828
+
829
+ // Plain text
830
+ html += `<div class="status-md-text">${renderInlineMd(line)}</div>`;
660
831
  }
832
+
833
+ html += '</div>';
834
+ return { html, hasLastChecked: lastCheckedIdx >= 0 };
835
+ }
836
+
837
+ function renderInlineMd(text) {
838
+ let s = escapeHtml(text);
839
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
840
+ s = s.replace(/`(.+?)`/g, '<code class="status-md-code">$1</code>');
841
+ return s;
661
842
  }
662
843
 
844
+ // ── Conversation event renderer ─────────────────────────────────────────────
845
+
663
846
  function renderConvEvent(event) {
664
847
  switch (event.type) {
665
848
  case "message_update": {
@@ -705,20 +888,81 @@ function renderConvEvent(event) {
705
888
  }
706
889
  }
707
890
 
708
- function closeConversation() {
709
- if (conversationTimer) {
710
- clearInterval(conversationTimer);
711
- conversationTimer = null;
891
+ // ── Auto-scroll logic ───────────────────────────────────────────────────────
892
+
893
+ function scrollToLastChecked() {
894
+ const el = document.getElementById('last-checked');
895
+ if (!el) return;
896
+ isProgrammaticScroll = true;
897
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
898
+ setTimeout(() => { isProgrammaticScroll = false; }, 600);
899
+ }
900
+
901
+ function updateTrackingHighlight() {
902
+ const container = $terminalBody.querySelector('.status-md-content');
903
+ if (container) {
904
+ container.classList.toggle('tracking', autoScrollOn && viewerMode === 'status-md');
905
+ }
906
+ }
907
+
908
+ $autoScrollCheckbox.addEventListener('change', () => {
909
+ autoScrollOn = $autoScrollCheckbox.checked;
910
+ if (autoScrollOn) {
911
+ if (viewerMode === 'conversation') {
912
+ isProgrammaticScroll = true;
913
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
914
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
915
+ } else if (viewerMode === 'status-md') {
916
+ scrollToLastChecked();
917
+ updateTrackingHighlight();
918
+ }
919
+ } else {
920
+ updateTrackingHighlight();
921
+ }
922
+ });
923
+
924
+ $terminalBody.addEventListener('scroll', () => {
925
+ if (isProgrammaticScroll) return;
926
+
927
+ if (viewerMode === 'conversation') {
928
+ const isAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 30;
929
+ if (isAtBottom && !autoScrollOn) {
930
+ autoScrollOn = true;
931
+ $autoScrollCheckbox.checked = true;
932
+ } else if (!isAtBottom && autoScrollOn) {
933
+ autoScrollOn = false;
934
+ $autoScrollCheckbox.checked = false;
935
+ }
936
+ } else if (viewerMode === 'status-md') {
937
+ if (autoScrollOn) {
938
+ autoScrollOn = false;
939
+ $autoScrollCheckbox.checked = false;
940
+ updateTrackingHighlight();
941
+ }
942
+ }
943
+ });
944
+
945
+ // ── Close viewer ────────────────────────────────────────────────────────────
946
+
947
+ function closeViewer() {
948
+ if (viewerTimer) {
949
+ clearInterval(viewerTimer);
950
+ viewerTimer = null;
712
951
  }
713
- activeSession = null;
714
- $terminalPanel.style.display = "none";
715
- $terminalBody.innerHTML = "";
952
+ viewerMode = null;
953
+ viewerTarget = null;
954
+ autoScrollOn = false;
955
+ convRenderedLines = 0;
956
+ lastStatusMdText = '';
957
+ $terminalPanel.style.display = 'none';
958
+ $terminalBody.innerHTML = '';
716
959
  }
717
960
 
718
- $terminalClose.addEventListener("click", closeConversation);
961
+ $terminalClose.addEventListener('click', closeViewer);
719
962
 
720
- // Make viewConversation available globally for onclick handlers
963
+ // Make viewer functions available globally for onclick handlers
721
964
  window.viewConversation = viewConversation;
965
+ window.viewStatusMd = viewStatusMd;
722
966
 
723
967
  // ─── History ────────────────────────────────────────────────────────────────
724
968
 
@@ -62,11 +62,17 @@
62
62
  </div>
63
63
  </div>
64
64
 
65
- <!-- Terminal Viewer (hidden until a session is clicked) -->
65
+ <!-- Viewer Panel: Conversation + STATUS.md (hidden until activated) -->
66
66
  <div class="panel terminal-panel" id="terminal-panel" style="display:none;">
67
67
  <div class="panel-header">
68
68
  <span id="terminal-title">Terminal</span>
69
- <button class="terminal-close" id="terminal-close" title="Close terminal">✕</button>
69
+ <div class="terminal-controls">
70
+ <label class="auto-scroll-label">
71
+ <input type="checkbox" id="auto-scroll-checkbox">
72
+ <span id="auto-scroll-text">Follow feed</span>
73
+ </label>
74
+ <button class="terminal-close" id="terminal-close" title="Close viewer">✕</button>
75
+ </div>
70
76
  </div>
71
77
  <div class="panel-body" id="terminal-body"></div>
72
78
  </div>
@@ -439,7 +439,7 @@ body {
439
439
 
440
440
  .task-row {
441
441
  display: grid;
442
- grid-template-columns: 36px 100px 90px 80px 200px 1fr;
442
+ grid-template-columns: 36px 24px 100px 90px 80px 200px 1fr;
443
443
  align-items: center;
444
444
  gap: 8px;
445
445
  padding: 8px 14px;
@@ -700,6 +700,190 @@ body {
700
700
  color: var(--text-faint);
701
701
  }
702
702
 
703
+ /* ─── Viewer Eye Button (task row) ─────────────────────────────────────── */
704
+
705
+ .task-actions {
706
+ display: flex;
707
+ align-items: center;
708
+ justify-content: center;
709
+ }
710
+
711
+ .viewer-eye-btn {
712
+ background: none;
713
+ border: none;
714
+ cursor: pointer;
715
+ font-size: 0.75rem;
716
+ padding: 2px;
717
+ border-radius: var(--radius-sm);
718
+ opacity: 0.35;
719
+ transition: opacity 0.2s, background 0.2s;
720
+ line-height: 1;
721
+ }
722
+
723
+ .viewer-eye-btn:hover {
724
+ opacity: 0.8;
725
+ background: rgba(88,166,255,0.1);
726
+ }
727
+
728
+ .viewer-eye-btn.active {
729
+ opacity: 1;
730
+ background: rgba(88,166,255,0.15);
731
+ }
732
+
733
+ .tmux-view-btn.active {
734
+ background: var(--accent);
735
+ border-color: var(--accent);
736
+ }
737
+
738
+ /* ─── Viewer Controls (auto-scroll checkbox + close) ───────────────────── */
739
+
740
+ .terminal-controls {
741
+ display: flex;
742
+ align-items: center;
743
+ gap: 12px;
744
+ }
745
+
746
+ .auto-scroll-label {
747
+ display: flex;
748
+ align-items: center;
749
+ gap: 6px;
750
+ cursor: pointer;
751
+ font-size: 0.75rem;
752
+ color: var(--text-muted);
753
+ user-select: none;
754
+ white-space: nowrap;
755
+ }
756
+
757
+ .auto-scroll-label input[type="checkbox"] {
758
+ appearance: none;
759
+ -webkit-appearance: none;
760
+ width: 14px;
761
+ height: 14px;
762
+ border: 1px solid var(--border);
763
+ border-radius: 3px;
764
+ background: var(--bg-inset);
765
+ cursor: pointer;
766
+ position: relative;
767
+ flex-shrink: 0;
768
+ }
769
+
770
+ .auto-scroll-label input[type="checkbox"]:checked {
771
+ background: var(--accent-dim);
772
+ border-color: var(--accent);
773
+ }
774
+
775
+ .auto-scroll-label input[type="checkbox"]:checked::after {
776
+ content: '✓';
777
+ position: absolute;
778
+ top: -1px;
779
+ left: 2px;
780
+ font-size: 11px;
781
+ color: #fff;
782
+ }
783
+
784
+ /* ─── STATUS.md Rendered Content ───────────────────────────────────────── */
785
+
786
+ .status-md-content {
787
+ padding: 12px 16px;
788
+ font-family: var(--font-sans);
789
+ font-size: 0.85rem;
790
+ line-height: 1.6;
791
+ }
792
+
793
+ .status-md-h1 {
794
+ font-size: 1.15rem;
795
+ font-weight: 700;
796
+ color: var(--text);
797
+ margin: 16px 0 8px;
798
+ padding-bottom: 4px;
799
+ border-bottom: 1px solid var(--border-subtle);
800
+ }
801
+
802
+ .status-md-h1:first-child { margin-top: 0; }
803
+
804
+ .status-md-h2 {
805
+ font-size: 1rem;
806
+ font-weight: 600;
807
+ color: var(--text);
808
+ margin: 14px 0 6px;
809
+ }
810
+
811
+ .status-md-h3 {
812
+ font-size: 0.9rem;
813
+ font-weight: 600;
814
+ color: var(--text-muted);
815
+ margin: 12px 0 4px;
816
+ }
817
+
818
+ .status-md-h4 {
819
+ font-size: 0.85rem;
820
+ font-weight: 600;
821
+ color: var(--text-muted);
822
+ margin: 10px 0 4px;
823
+ }
824
+
825
+ .status-md-check {
826
+ display: flex;
827
+ align-items: flex-start;
828
+ gap: 8px;
829
+ padding: 3px 8px;
830
+ border-radius: var(--radius-sm);
831
+ border-left: 2px solid transparent;
832
+ transition: background 0.2s, border-color 0.2s;
833
+ }
834
+
835
+ .status-md-check.checked {
836
+ color: var(--text-muted);
837
+ }
838
+
839
+ .status-md-check.checked .check-box {
840
+ color: var(--green);
841
+ }
842
+
843
+ .status-md-check.unchecked {
844
+ color: var(--text);
845
+ }
846
+
847
+ .status-md-check.unchecked .check-box {
848
+ color: var(--text-faint);
849
+ }
850
+
851
+ .check-box {
852
+ flex-shrink: 0;
853
+ font-size: 0.85rem;
854
+ line-height: 1.6;
855
+ }
856
+
857
+ /* Progress frontier highlight — only when tracking is active */
858
+ .status-md-content.tracking .status-md-check.last-checked {
859
+ border-left-color: var(--accent);
860
+ background: rgba(88, 166, 255, 0.06);
861
+ }
862
+
863
+ .status-md-li {
864
+ padding: 2px 8px;
865
+ color: var(--text);
866
+ }
867
+
868
+ .status-md-text {
869
+ padding: 2px 8px;
870
+ color: var(--text);
871
+ }
872
+
873
+ .status-md-spacer {
874
+ height: 8px;
875
+ }
876
+
877
+ .status-md-code {
878
+ font-family: var(--font-mono);
879
+ font-size: 0.8rem;
880
+ padding: 1px 5px;
881
+ background: var(--bg-surface);
882
+ border: 1px solid var(--border-subtle);
883
+ border-radius: 3px;
884
+ color: var(--accent);
885
+ }
886
+
703
887
  /* ─── Errors Panel ─────────────────────────────────────────────────────── */
704
888
 
705
889
  .errors-panel .panel-header { color: var(--red); }
@@ -750,7 +934,7 @@ body {
750
934
 
751
935
  @media (max-width: 900px) {
752
936
  .task-row {
753
- grid-template-columns: 36px 90px 80px 70px 1fr;
937
+ grid-template-columns: 36px 24px 90px 80px 70px 1fr;
754
938
  }
755
939
  .task-row .task-step { display: none; }
756
940
  .progress-bar-bg { width: 160px; }
@@ -426,6 +426,57 @@ function serveHistoryEntry(req, res, batchId) {
426
426
  res.end(JSON.stringify(entry));
427
427
  }
428
428
 
429
+ /** GET /api/status-md/:taskId — return raw STATUS.md content for a task. */
430
+ function serveStatusMd(req, res, taskId) {
431
+ if (!/^[\w-]+$/.test(taskId)) {
432
+ res.writeHead(400, { "Content-Type": "text/plain" });
433
+ res.end("Invalid task ID");
434
+ return;
435
+ }
436
+
437
+ const state = loadBatchState();
438
+ if (!state) {
439
+ res.writeHead(404, { "Content-Type": "application/json" });
440
+ res.end(JSON.stringify({ error: "No batch state" }));
441
+ return;
442
+ }
443
+
444
+ const task = (state.tasks || []).find(t => t.taskId === taskId);
445
+ if (!task) {
446
+ res.writeHead(404, { "Content-Type": "application/json" });
447
+ res.end(JSON.stringify({ error: "Task not found" }));
448
+ return;
449
+ }
450
+
451
+ const effectiveFolder = resolveTaskFolder(task, state);
452
+ if (!effectiveFolder) {
453
+ res.writeHead(404, { "Content-Type": "application/json" });
454
+ res.end(JSON.stringify({ error: "Cannot resolve task folder" }));
455
+ return;
456
+ }
457
+
458
+ // Try effective folder, then archive
459
+ const candidates = [effectiveFolder];
460
+ const archiveBase = effectiveFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
461
+ if (archiveBase !== effectiveFolder) candidates.push(archiveBase);
462
+
463
+ for (const folder of candidates) {
464
+ const statusPath = path.join(folder, "STATUS.md");
465
+ try {
466
+ const content = fs.readFileSync(statusPath, "utf-8");
467
+ res.writeHead(200, {
468
+ "Content-Type": "text/plain; charset=utf-8",
469
+ "Access-Control-Allow-Origin": "*",
470
+ });
471
+ res.end(content);
472
+ return;
473
+ } catch { continue; }
474
+ }
475
+
476
+ res.writeHead(404, { "Content-Type": "application/json" });
477
+ res.end(JSON.stringify({ error: "STATUS.md not found" }));
478
+ }
479
+
429
480
  // ─── HTTP Server ────────────────────────────────────────────────────────────
430
481
 
431
482
  function createServer() {
@@ -452,6 +503,9 @@ function createServer() {
452
503
  } else if (pathname.startsWith("/api/history/") && req.method === "GET") {
453
504
  const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
454
505
  serveHistoryEntry(req, res, batchId);
506
+ } else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
507
+ const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
508
+ serveStatusMd(req, res, taskId);
455
509
  } else {
456
510
  serveStatic(req, res);
457
511
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",