diff --git a/README.md b/README.md index c39f37b..232e923 100644 --- a/README.md +++ b/README.md @@ -387,18 +387,41 @@ agentboard help ## VS Code extension -A webview dashboard extension ships in `extensions/vscode/`. It gives you a live multi-session panel without leaving your editor: +A webview dashboard extension ships in `extensions/vscode/`. It gives you a live multi-session panel without leaving your editor. -- **Live tab** — real-time session grid (one column per active Claude Code session) showing cost, context %, branch, current stream, file/bash activity feed, workflow agent status, and sub-agent tracking. An active-streams row lists all non-closed streams from `.platform/work/`. -- **Catalog tab** — three columns: Skills (`~40`), Roles (`~26`), and CLI Commands (`14`). Each card shows a description and "used by" badges; clicking expands the full protocol. +### Live tab -**Install:** +Real-time session grid — one column per active Claude Code session: + +- **Session header** — deterministic pet name (e.g. `frost-condor`), model, cost, runtime, context bar, git branch, last-active time, current role and skill +- **`⌨ terminal` button** — focuses the VS Code terminal that belongs to that session; works across multiple concurrent sessions in the same project by matching process tree + elapsed time +- **Activity feed** — every file edited or command run, with: + - `+N / -N` line deltas + - **⚠ warning** when ≥ 50 lines changed (amber) or ≥ 150 lines changed (orange) + - **Size badge** — `500L` amber · `800L` orange · `1kL` red — showing how monolithic the file is + - **Click → options menu** — "Open diff" (HEAD ↔ working tree via VS Code diff viewer) or "Copy path" (absolute path to clipboard) +- **Workflow agents** — running sub-agents listed with label; click to expand/collapse long labels; done agents older than 5 min collapse to `✓ N done earlier` +- **Active streams** — all non-closed streams from `.platform/work/ACTIVE.md` + +### Catalog tab + +Three columns: Skills (`~40`), Roles (`~26`), and CLI Commands (`14`). Each card shows a description and "used by" badges; clicking expands the full protocol. + +### Status line integration + +The `status-bridge.js` hook writes a deterministic session nickname to the Claude Code status line (e.g. `frost-condor · Opus 4.8 · $1.20 · …`). Names are stable — the same session ID always produces the same name. The word pool has 40 adjectives × 40 animals (1 600 combinations) with no visually similar words in the same pool, so concurrent sessions stay clearly distinct. + +### File size thresholds + +The same 500 / 800 / 1 000-line thresholds that power the activity badges are written into `workflow.md` as hard rules, so agents see the same signals and proactively flag or refuse to grow large files without a refactor plan. + +### Install ```bash cd extensions/vscode npm install && npm run compile npx @vscode/vsce package -code --install-extension agentboard-2.1.0.vsix +code --install-extension agentboard-2.2.1.vsix ``` Open the dashboard via **Agentboard: Open Dashboard** in the command palette (`Cmd+Shift+P`). Lightweight sidebar tree views (Session Status, Streams, Catalog, Sessions, Worktrees) remain available as an alternative. diff --git a/bin/ab b/bin/ab index 7961380..6b652a9 100755 --- a/bin/ab +++ b/bin/ab @@ -43,6 +43,7 @@ source "$AGENTBOARD_ROOT/lib/agentboard/commands/streams.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/stream_resolve.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/handoff.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/handoff_render.sh" +source "$AGENTBOARD_ROOT/lib/agentboard/commands/delegate.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/progress.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/checkpoint.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/recover.sh" diff --git a/bin/agentboard b/bin/agentboard index 2ece210..8a9a2af 100755 --- a/bin/agentboard +++ b/bin/agentboard @@ -43,6 +43,7 @@ source "$AGENTBOARD_ROOT/lib/agentboard/commands/streams.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/stream_resolve.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/handoff.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/handoff_render.sh" +source "$AGENTBOARD_ROOT/lib/agentboard/commands/delegate.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/progress.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/checkpoint.sh" source "$AGENTBOARD_ROOT/lib/agentboard/commands/recover.sh" @@ -151,6 +152,7 @@ main() { current-stream) cmd_current_stream "$@" ;; next-action) cmd_next_action "$@" ;; handoff) cmd_handoff "$@" ;; + delegate) cmd_delegate "$@" ;; progress) cmd_progress "$@" ;; checkpoint) cmd_checkpoint "$@" ;; recover) cmd_recover "$@" ;; @@ -172,7 +174,6 @@ main() { start) cmd_cp_start "$@" ;; stop) cmd_cp_stop "$@" ;; sessions) cmd_sessions "$@" ;; - delegate) cmd_delegate "$@" ;; worktree) cmd_worktree "$@" ;; version|-v|--version) cmd_version ;; help|-h|--help) cmd_help ;; diff --git a/extensions/vscode/agentboard-2.3.0.vsix b/extensions/vscode/agentboard-2.3.0.vsix new file mode 100644 index 0000000..742af17 Binary files /dev/null and b/extensions/vscode/agentboard-2.3.0.vsix differ diff --git a/extensions/vscode/agentboard-2.3.1.vsix b/extensions/vscode/agentboard-2.3.1.vsix new file mode 100644 index 0000000..eb62bc1 Binary files /dev/null and b/extensions/vscode/agentboard-2.3.1.vsix differ diff --git a/extensions/vscode/media/dashboard.js b/extensions/vscode/media/dashboard.js index 96fe88f..d8ca41a 100644 --- a/extensions/vscode/media/dashboard.js +++ b/extensions/vscode/media/dashboard.js @@ -93,6 +93,49 @@ function ctxBar(pct){ const c=used<50?'#4caf50':used<75?'#ff9800':'#f44336'; return ''+'█'.repeat(fill)+'░'.repeat(10-fill)+' '+used+'%'; } +// Role launcher — selectable role cards with linked skills + launch button +window._selectedRole = window._selectedRole || null; +window._rolesData = window._rolesData || []; + +function renderRolesCol(listId, roles, accentColor) { + window._catExpanded = window._catExpanded || new Set(); + var selected = window._selectedRole; + var h = roles.slice(0, 200).map(function(role, idx) { + var eid = listId + '-' + idx; + var isOpen = window._catExpanded.has(eid); + var hasMore = role.fullDescription && role.fullDescription.length > 10; + var isSelected = selected === (role.slug || role.name); + var usedBy = role.usedBy && role.usedBy.length ? role.usedBy : null; + var linked = role.linkedSkills && role.linkedSkills.length ? role.linkedSkills : []; + + var cardStyle = 'cursor:pointer;border-radius:5px;padding:2px 4px;margin:-2px -4px;transition:background .1s;'; + if (isSelected) cardStyle += 'background:rgba(156,106,247,.1);outline:1px solid rgba(156,106,247,.35);'; + + var row = '
'; + row += '
'; + row += ''+esc(role.name)+''; + if (hasMore) row += ''+(isOpen?'▾':'▸')+''; + if (usedBy) row += usedBy.map(function(n){return ''+esc(n)+'';}).join(''); + row += '
'; + if (role.description) row += ''+esc(role.description.slice(0,120))+''; + if (hasMore) row += '
'+esc(role.fullDescription||'')+'
'; + // Launch panel — only when selected + if (isSelected) { + row += '
'; + if (linked.length) { + row += '
'; + row += linked.map(function(sk){return ''+esc(sk)+'';}).join(''); + row += '
'; + } + row += ''; + row += '
'; + } + row += '
'; + return row; + }).join(''); + html(listId, h); +} + function renderCatalogCol(listId, items, accentColor) { const MAX = 200; window._catExpanded = window._catExpanded || new Set(); @@ -153,13 +196,9 @@ function applyUpdate(d){ txt('now-stats',summaryParts.join(' · ')); } else if(d.hasLive){ nowEl.classList.remove('idle');dot.classList.remove('idle'); - const isCompact=d.isInLongOp&&ctxNow>=75; if(isWorkflow){ stateEl.textContent='WORKFLOW';stateEl.style.color='#4a9eff'; dot.style.background='#4a9eff';dot.style.animation='pulse 0.6s ease-in-out infinite'; - } else if(isCompact){ - stateEl.textContent='COMPACTING';stateEl.style.color='#9c6af7'; - dot.style.background='#9c6af7';dot.style.animation='pulse 0.6s ease-in-out infinite'; } else { stateEl.textContent='LIVE';stateEl.style.color='#4caf50'; dot.style.background='#4caf50';dot.style.animation='pulse 1.5s ease-in-out infinite'; @@ -200,42 +239,74 @@ function applyUpdate(d){ } } txt('now-desc',d.streamDesc||''); - const ctxUsed=d.ctxPct!==null&&d.ctxPct!==undefined?Math.round(100-d.ctxPct):0; - const isCompacting=d.isInLongOp&&ctxUsed>=75; if(lopEl){ lopEl.className='now-longop'+(d.isInLongOp?' on':''); - lopEl.textContent=isCompacting - ?'⟳ Context at '+ctxUsed+'% — compaction in progress (will update when complete)' - :'⟳ Running long operation — last tool call completed >90s ago'; - lopEl.style.color=isCompacting?'#9c6af7':'#ff9800'; + lopEl.textContent='⟳ Running long operation — last tool call completed >90s ago'; + lopEl.style.color='#ff9800'; } } - // file activity - var _totalFiles = d.totalUniqueFiles || (d.fileActivity && d.fileActivity.length) || 0; - var _shownFiles = d.fileActivity && d.fileActivity.length || 0; + // file activity — use rich session data (with isNew/isDeleted/added/deleted) when available + var _singleSess = (d.activeSessions && d.activeSessions.length === 1) ? d.activeSessions[0] : null; + var _richActivity = _singleSess ? (_singleSess.activity || null) : null; + var _singleRoot = _singleSess ? (_singleSess.root || '') : ''; + var _actFiles = _richActivity || d.fileActivity || []; + var _totalFiles = d.totalUniqueFiles || _actFiles.length || 0; + var _shownFiles = _actFiles.length || 0; var _actLabel = 'Activity this session'; if (_totalFiles > 0) { _actLabel += ' · ' + _totalFiles + ' file' + (_totalFiles !== 1 ? 's' : ''); if (_shownFiles < _totalFiles) _actLabel += ' (showing ' + _shownFiles + ')'; } txt('fa-ttl', _actLabel); - html('fa-list', d.fileActivity&&d.fileActivity.length ? d.fileActivity.map(function(f){ - const isSkillEntry=f.tool==='Skill'; - const isBash=f.tool==='Bash'; - const icon=TOOL_ICON[f.tool]||'·'; - let fname; - if(isSkillEntry) fname='/'+f.file; - else fname=f.file; // full path/command — let .fa-file word-break handle layout - const color=isSkillEntry?'color:#4caf84;font-weight:600':isBash?'color:#ff9800':''; - return '
' - +''+icon+'' - +'
' - +''+esc(fname)+'' - +(f.count>1?'×'+f.count+'':'') - +''+relTime(f.lastTs)+'' - +'
' - +'
'; + html('fa-list', _actFiles.length ? _actFiles.map(function(f){ + const TOOL_ICON_SS={Edit:'✏',Write:'✏',Bash:'$',Read:'👁',WebSearch:'⌕',WebFetch:'⌕',Agent:'◈',Skill:'⚡'}; + const icon = TOOL_ICON_SS[f.tool] || TOOL_ICON[f.tool] || '·'; + const isCmd = f.file.startsWith('$ '); + const isEdited = (f.tool === 'Edit' || f.tool === 'Write' || f.tool === 'MultiEdit') && !isCmd; + const ago = relTime(f.lastTs); + var totalChanged = (f.added || 0) + (f.deleted || 0); + var editWarn = ''; + if (isEdited && totalChanged >= 50) { + var warnColor = totalChanged >= 150 ? '#ff7043' : '#f0b429'; + editWarn = ''; + } + var sizeBadge = ''; + if (f.lineCount) { + var lc = f.lineCount; + var sizeColor = lc >= 1000 ? '#ef5350' : lc >= 800 ? '#ff7043' : lc >= 500 ? '#f0b429' : ''; + if (sizeColor) { + var sizeLabel = lc >= 1000 ? (Math.round(lc/100)/10)+'k' : lc+''; + sizeBadge = ''+sizeLabel+'L'; + } + } + var rowBg = f.isNew ? 'background:rgba(40,200,80,.07);border-left:2px solid rgba(40,200,80,.35);padding-left:4px;' : f.isDeleted ? 'background:rgba(220,60,60,.07);border-left:2px solid rgba(220,60,60,.35);padding-left:4px;' : ''; + var diffAttrs = isEdited + ? ' data-open-diff="'+esc(f.file)+'" data-session-root="'+esc(_singleRoot)+'"'+(f.isNew?' data-is-new="1"':'')+(f.isDeleted?' data-is-deleted="1"':'') + +' data-line-count="'+(f.lineCount||0)+'"' + +' data-added="'+(f.added||0)+'" data-deleted="'+(f.deleted||0)+'" data-total-changed="'+totalChanged+'"' + +' data-session-id="'+esc((_singleSess&&_singleSess.sessionId)||'')+'"' + +' data-shell-pid="'+((_singleSess&&_singleSess.shellPid)||0)+'"' + +' data-session-nick="'+esc((_singleSess&&_singleSess.nick)||'')+'"' + +' title="Click for options" style="cursor:pointer;'+rowBg+'"' + : (rowBg ? ' style="'+rowBg+'"' : ''); + return '
' + + ''+icon+'' + + '
' + + ''+esc(f.file)+'' + + (isEdited && (f.added != null || f.deleted != null) + ? '' + + (f.added ? '+'+f.added+'' : '') + + (f.added && f.deleted ? ' / ' : '') + + (f.deleted ? '-'+f.deleted+'' : '') + + '' + : '') + + (f.count > 1 ? '×'+f.count+'' : '') + + ''+ago+'' + + sizeBadge + editWarn + + (f.committed && f.added == null && f.deleted == null ? '' : '') + + '
' + + '
'; }).join('') : '
No edits or commands yet this session
'); // agents / workflow panel @@ -374,7 +445,8 @@ function applyUpdate(d){ + '' + '' + esc(displayName) + '' + '' + esc(s.model) + '' - + '' + + '' + + '' + '' + '
' + (s.stream ? 'Stream' + esc(s.stream) + '' : '') @@ -594,9 +666,16 @@ function applyUpdate(d){ } } + var rowBg = f.isNew ? 'background:rgba(40,200,80,.07);border-left:2px solid rgba(40,200,80,.35);padding-left:4px;' : f.isDeleted ? 'background:rgba(220,60,60,.07);border-left:2px solid rgba(220,60,60,.35);padding-left:4px;' : ''; const diffAttrs = isEdited - ? ' data-open-diff="'+esc(f.file)+'" data-session-root="'+esc(sessRoot)+'" title="Click for options" style="cursor:pointer"' - : ''; + ? ' data-open-diff="'+esc(f.file)+'" data-session-root="'+esc(sessRoot)+'"'+(f.isNew?' data-is-new="1"':'')+(f.isDeleted?' data-is-deleted="1"':'') + +' data-line-count="'+(f.lineCount||0)+'"' + +' data-added="'+(f.added||0)+'" data-deleted="'+(f.deleted||0)+'" data-total-changed="'+totalChanged+'"' + +' data-session-id="'+esc(s.sessionId||'')+'"' + +' data-shell-pid="'+(s.shellPid||0)+'"' + +' data-session-nick="'+esc(s.nick||'')+'"' + +' title="Click for options" style="cursor:pointer;'+rowBg+'"' + : (rowBg ? ' style="'+rowBg+'"' : ''); return '
' + '' + icon + '' + '
' @@ -612,6 +691,7 @@ function applyUpdate(d){ + '' + ago + '' + sizeBadge + editWarn + + (f.committed && f.added == null && f.deleted == null ? '' : '') + '
' + '
'; }).join('') || '
No activity yet
'; @@ -695,7 +775,8 @@ function applyUpdate(d){ txt('cnt-roles',String(d.roleCount)); txt('cnt-cmds',String(d.commands.length)); renderCatalogCol('list-skills',d.skills,'#4a9eff'); - renderCatalogCol('list-roles',d.roles,'#9c6af7'); + window._rolesData = d.roles; + renderRolesCol('list-roles',d.roles,'#9c6af7'); renderCatalogCol('list-cmds',d.commands,'#888'); // footer — global counts only (session-specific data is shown on each session card) @@ -720,6 +801,8 @@ document.addEventListener('click',function(e){ const t=e.target; // Refresh button if(t.id==='refresh-btn'||t.closest('#refresh-btn')){ + var rbtn=document.getElementById('refresh-btn'); + if(rbtn){rbtn.textContent='↻ Refreshing…';rbtn.disabled=true;setTimeout(function(){rbtn.textContent='↻ Refresh';rbtn.disabled=false;},1200);} vscode.postMessage({command:'refresh'});return; } // File options menu (diff / copy path) @@ -729,9 +812,15 @@ document.addEventListener('click',function(e){ const menu=document.getElementById('_file-menu'); const fp=menu._filePath||''; const sr=menu._sessionRoot||''; if(fm.dataset.fm==='diff'){ - vscode.postMessage({command:'openDiff',filePath:fp,sessionRoot:sr}); + vscode.postMessage({command:'openDiff',filePath:fp,sessionRoot:sr,isNew:menu._isNew||false}); } else if(fm.dataset.fm==='copy'){ vscode.postMessage({command:'copyPath',filePath:fp,sessionRoot:sr}); + } else if(fm.dataset.fm==='explain-change'){ + vscode.postMessage({command:'explainChange',filePath:fp,sessionRoot:sr,added:menu._added||0,deleted:menu._deleted||0,totalChanged:menu._totalChanged||0,shellPid:menu._shellPid||0,sessionNick:menu._sessionNick||'',sessionId:menu._sessionId||''}); + } else if(fm.dataset.fm==='refactor-here'){ + vscode.postMessage({command:'refactorInSession',filePath:fp,sessionRoot:sr,lineCount:menu._lineCount||0,shellPid:menu._shellPid||0,sessionNick:menu._sessionNick||'',sessionId:menu._sessionId||''}); + } else if(fm.dataset.fm==='refactor-new'){ + vscode.postMessage({command:'refactorNewSession',filePath:fp,sessionRoot:sr,lineCount:menu._lineCount||0}); } menu.style.display='none'; } @@ -750,16 +839,58 @@ document.addEventListener('click',function(e){ if(!menu){ menu=document.createElement('div'); menu.id='_file-menu'; - menu.style.cssText='position:fixed;z-index:9999;background:#252526;border:1px solid rgba(255,255,255,.12);border-radius:5px;box-shadow:0 4px 16px rgba(0,0,0,.6);display:none;flex-direction:column;min-width:170px;overflow:hidden;padding:3px 0'; - menu.innerHTML='
Open diff
' - +'
Copy path
'; + menu.style.cssText='position:fixed;z-index:9999;background:#252526;border:1px solid rgba(255,255,255,.12);border-radius:5px;box-shadow:0 4px 16px rgba(0,0,0,.6);display:none;flex-direction:column;min-width:200px;overflow:hidden;padding:3px 0'; document.body.appendChild(menu); } menu._filePath=diffEl.dataset.openDiff||''; menu._sessionRoot=diffEl.dataset.sessionRoot||''; + menu._isNew=diffEl.dataset.isNew==='1'; + menu._isDeleted=diffEl.dataset.isDeleted==='1'; + menu._lineCount=parseInt(diffEl.dataset.lineCount||'0',10); + menu._added=parseInt(diffEl.dataset.added||'0',10); + menu._deleted=parseInt(diffEl.dataset.deleted||'0',10); + menu._totalChanged=parseInt(diffEl.dataset.totalChanged||'0',10); + menu._sessionId=diffEl.dataset.sessionId||''; + menu._shellPid=parseInt(diffEl.dataset.shellPid||'0',10); + menu._sessionNick=diffEl.dataset.sessionNick||''; + var _sep='
'; + var _fmItem=function(fm,icon,label,color,hint){ + var c=color||'#d4d4d4'; + var base='padding:7px 14px;cursor:pointer;font-size:12px;color:'+c+';display:flex;align-items:center;gap:8px;transition:background .12s,border-color .12s;border-left:2px solid transparent;box-sizing:border-box'; + var over='this.style.background=\'rgba(255,255,255,.1)\';this.style.borderLeftColor=\''+c+'\''; + var out='this.style.background=\'\';this.style.borderLeftColor=\'transparent\''; + return '
' + +''+icon+'' + +''+label+'' + +(hint?''+hint+'':'') + +'
'; + }; + var _diffHint=''; + if(menu._added||menu._deleted){ + _diffHint=(menu._added?'+'+menu._added+'':'') + +(menu._added&&menu._deleted?' / ':'') + +(menu._deleted?'-'+menu._deleted+'':''); + } + var _mHtml = _fmItem('diff', menu._isNew||menu._isDeleted?'↗️':'↔️', menu._isNew||menu._isDeleted?'Open file':'Open diff','#d4d4d4',_diffHint); + _mHtml += _fmItem('copy','📋','Copy path'); + if(menu._totalChanged>=50){ + var _wHint=(menu._added?'+'+menu._added+'':'') + +(menu._added&&menu._deleted?' / ':'') + +(menu._deleted?'-'+menu._deleted+'':''); + _mHtml += _sep; + _mHtml += _fmItem('explain-change','🔍','Explain this change','#89ddff',_wHint); + } + if(menu._lineCount>=500){ + var _lcTier=menu._lineCount>=1000?'🔴':menu._lineCount>=800?'🟠':'🟡'; + var _lcHint=''+_lcTier+' '+menu._lineCount+'L'; + if(menu._totalChanged<50) _mHtml += _sep; + _mHtml += _fmItem('refactor-here','⚡','Refactor in this session','#c792ea',_lcHint); + _mHtml += _fmItem('refactor-new','✨','Refactor in new session','#82aaff'); + } + menu.innerHTML=_mHtml; var rect=diffEl.getBoundingClientRect(); menu.style.display='flex'; - menu.style.left=Math.min(e.clientX, window.innerWidth-180)+'px'; + menu.style.left=Math.min(e.clientX, window.innerWidth-220)+'px'; menu.style.top=(rect.bottom+2)+'px'; return; } @@ -778,6 +909,13 @@ document.addEventListener('click',function(e){ } return; } + // Close session button + const closeSessBtn = t.closest('[data-close-session]'); + if(closeSessBtn){ + e.stopPropagation(); + vscode.postMessage({command:'closeSession',sessionId:closeSessBtn.dataset.closeSession||''}); + return; + } // Focus terminal button const ftBtn = t.closest('[data-focus-terminal]'); if(ftBtn){ @@ -839,6 +977,21 @@ document.addEventListener('click',function(e){ } return; } + // Role launch button + const launchBtn = t.closest('[data-launch-role]'); + if (launchBtn) { + e.stopPropagation(); + vscode.postMessage({command:'launchRole',slug:launchBtn.dataset.launchRole||'',name:launchBtn.dataset.launchRoleName||''}); + return; + } + // Role card selection (toggle) + const roleCard = t.closest('[data-role-select]'); + if (roleCard && !t.closest('[data-launch-role]')) { + var slug2 = roleCard.dataset.roleSelect; + window._selectedRole = window._selectedRole === slug2 ? null : slug2; + renderRolesCol('list-roles', window._rolesData || [], '#9c6af7'); + // Don't return — still allow data-cat-toggle to fire for expand + } // Catalog item expand/collapse const catToggle = t.closest('[data-cat-toggle]'); if (catToggle) { diff --git a/extensions/vscode/out/dashboardPanel.js b/extensions/vscode/out/dashboardPanel.js index d1aa051..ae2a7dc 100644 --- a/extensions/vscode/out/dashboardPanel.js +++ b/extensions/vscode/out/dashboardPanel.js @@ -142,6 +142,20 @@ function readSkills(root) { function readRoles(root) { const dir = path.join(root, ".platform", "roles"); try { + // Parse explicit pairs from INDEX.md: `role-slug`+ab-skill + const indexPairs = new Map(); + try { + const indexContent = fs.readFileSync(path.join(dir, "INDEX.md"), "utf8"); + const pairRe = /`([a-z][a-z-]+)`\+([a-z][a-z-]+)/g; + let m; + while ((m = pairRe.exec(indexContent)) !== null) { + const [, roleSlug, skillSlug] = m; + if (!indexPairs.has(roleSlug)) + indexPairs.set(roleSlug, []); + indexPairs.get(roleSlug).push(skillSlug); + } + } + catch { /* no INDEX.md */ } return fs.readdirSync(dir).filter(f => f.endsWith(".md") && f !== "INDEX.md").flatMap(f => { try { const content = fs.readFileSync(path.join(dir, f), "utf8"); @@ -149,7 +163,13 @@ function readRoles(root) { const slug = path.basename(f, ".md"); const afterFm = content.replace(/^---[\s\S]*?---\n?/, '').trim(); const fullDescription = extractProse(afterFm); - return [{ name: fm.name ?? fm.slug ?? slug, slug, description: fm.mission ?? fm.description ?? fm.objective ?? '', fullDescription }]; + // Merge INDEX.md pairs with ab-* mentions found in the role file body + const linked = new Set(indexPairs.get(slug) ?? []); + const bodyMatches = afterFm.match(/\bab-[a-z][a-z-]+/g) ?? []; + for (const s of bodyMatches) + linked.add(s); + const linkedSkills = [...linked]; + return [{ name: fm.name ?? fm.slug ?? slug, slug, description: fm.mission ?? fm.description ?? fm.objective ?? '', fullDescription, linkedSkills }]; } catch { return []; @@ -367,10 +387,13 @@ function readWorkflowPlan(root) { } function lastSkillFromEvents(events) { for (const e of events) { - if (e.tool === "Skill" && e.skill) - return e.skill; + if (e.tool !== "Skill") + continue; + const sk = e.skill || e.file || ""; + if (sk) + return { skill: sk, sessionId: e.session_id ?? "" }; } - return ""; + return { skill: "", sessionId: "" }; } function relTime(iso) { const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); @@ -442,8 +465,15 @@ class DashboardPanel { this._branchCache = { value: "", ts: 0 }; // Numstat cache: avoid blocking the extension host on every tick (30 s TTL per root) this._numstatCache = new Map(); + this._lineCountCache = new Map(); + // Branch-committed cache: files changed vs merge-base with develop/main (30 s TTL per root) + this._branchCommittedCache = new Map(); // HTTP backoff: slow down if server consistently absent this._httpFailStreak = 0; + this._lastDelegateKey = ""; // "|" dedup + this._lastDelegateTs = 0; // epoch ms of last handled delegate + // nick → terminal name cache so focusTerminal can match by session nick + this._sessionTerminalMap = new Map(); // nick → terminal.name this._workspaceRoot = workspaceRoot; this._panel = panel; const initialData = this._buildDataSync(); @@ -469,8 +499,398 @@ class DashboardPanel { } return; } + if (msg.command === "openDiff") { + const relPath = msg.filePath ?? ""; + const sessRoot = msg.sessionRoot ?? this._workspaceRoot; + const isNewFile = msg.isNew ?? false; + if (!relPath) + return; + // Resolve absolute path — try sessRoot first, then workspaceRoot + let absPath = path.isAbsolute(relPath) ? relPath : path.join(sessRoot, relPath); + if (!fs.existsSync(absPath)) { + const alt = path.join(this._workspaceRoot, relPath); + if (fs.existsSync(alt)) + absPath = alt; + } + const rightUri = vscode.Uri.file(absPath); + // New/untracked files have no HEAD version — open the file directly + if (isNewFile || !fs.existsSync(absPath)) { + if (fs.existsSync(absPath)) { + void vscode.window.showTextDocument(rightUri); + } + else { + void vscode.window.showWarningMessage(`File not found: ${relPath}`); + } + return; + } + // Check if file is tracked by git before attempting diff + try { + const { execSync: _ex } = require("child_process"); + const status = _ex(`git -C "${sessRoot}" status --porcelain -- "${relPath}" 2>/dev/null`).toString().trim(); + if (status.startsWith("??")) { + // Untracked — no HEAD version, just open the file + void vscode.window.showTextDocument(rightUri); + return; + } + } + catch { /* fall through to diff attempt */ } + const gitUri = rightUri.with({ + scheme: "git", + query: JSON.stringify({ path: absPath, ref: "HEAD" }), + }); + const fileName = path.basename(absPath); + void vscode.commands.executeCommand("vscode.diff", gitUri, rightUri, `${fileName}: HEAD ↔ Working Tree`).then(undefined, () => { + void vscode.window.showTextDocument(rightUri); + }); + return; + } + if (msg.command === "closeSession") { + const sessionId = msg.sessionId ?? ""; + if (!sessionId) + return; + this._deleteSessionFile(sessionId); + void this._update(); + return; + } + if (msg.command === "launchRole") { + const slug = msg.slug ?? ""; + const name = msg.name ?? slug; + if (!slug) + return; + const terminal = vscode.window.createTerminal({ name: `Claude · ${name}`, cwd: this._workspaceRoot }); + terminal.show(); + const prompt = `Adopt the ${name} role for this session. Read .platform/roles/${slug}.md for your full protocol, mission, and responsibilities. Ask me 2–3 focused intake questions to understand what I need, then begin working.`; + const escaped = prompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`"); + terminal.sendText(`claude "${escaped}"`, true); + return; + } + if (msg.command === "explainChange") { + const filePath = msg.filePath ?? ""; + const sessRoot = msg.sessionRoot ?? this._workspaceRoot; + const added = msg.added ?? 0; + const deleted = msg.deleted ?? 0; + const totalChanged = msg.totalChanged ?? 0; + const claudePid = msg.shellPid ?? 0; + const sessionNick = msg.sessionNick ?? ""; + if (!filePath) + return; + const absPath = path.isAbsolute(filePath) ? filePath : path.join(sessRoot, filePath); + const diffStat = (added ? `+${added}` : "") + (added && deleted ? " / " : "") + (deleted ? `-${deleted}` : ""); + const explainPrompt = `/ab-review + +A reviewer is auditing \`${absPath}\` and sees ⚠ **${totalChanged} lines changed** (${diffStat}). + +You made these changes — walk through your decisions clearly and directly. No preamble. Assume the reviewer is a senior engineer. + +═══ 1. PROBLEM & APPROACH +What problem were you solving in this file specifically, and why did you choose this approach over the alternatives you considered? + +═══ 2. KEY CHANGES — section by section +For each significant block you added or rewrote: +• What was there before (brief) +• What you changed it to +• Why this is strictly better + +═══ 3. WHAT WAS REMOVED AND WHY +For every significant deletion: what did you remove, why was it wrong/redundant/dead, and what (if anything) replaces it? + +═══ 4. DESIGN DECISIONS +Any non-obvious architectural choices — naming, structure, data flow, abstraction boundaries, dependency direction. State the reasoning and the tradeoff you accepted. + +═══ 5. RISK SURFACE +What is the most fragile thing about these changes? Any edge cases, regressions, or coupling risks introduced? What guards did you put in place? + +═══ 6. WHAT TO WATCH NEXT +Anything in this file that is still wrong, incomplete, or will need follow-up attention?`; + const terminals = [...vscode.window.terminals]; + void (async () => { + try { + const { execSync: _ex } = require("child_process"); + const termPids = await Promise.all(terminals.map(t => t.processId)); + let target; + if (claudePid > 0) { + try { + const ppidStr = _ex(`ps -p ${claudePid} -o ppid= 2>/dev/null`).toString().trim(); + const parentPid = parseInt(ppidStr, 10); + if (parentPid > 0) + target = terminals.find((_, i) => termPids[i] === parentPid); + } + catch { /* fall through */ } + if (!target) + target = terminals.find((_, i) => termPids[i] === claudePid); + } + if (!target && sessionNick && this._sessionTerminalMap.has(sessionNick)) { + const cachedName = this._sessionTerminalMap.get(sessionNick); + target = terminals.find(t2 => t2.name === cachedName); + } + if (!target && sessionNick) { + const nickLower = sessionNick.toLowerCase(); + target = terminals.find(t2 => t2.name.toLowerCase().includes(nickLower)); + } + if (!target && sessRoot) { + const sameCwd = []; + for (const term of terminals) { + try { + const wd = term.shellIntegration?.cwd?.fsPath ?? ""; + if (wd && (wd === sessRoot || wd.startsWith(sessRoot + "/"))) + sameCwd.push(term); + } + catch { /* */ } + } + if (sameCwd.length === 1) + target = sameCwd[0]; + } + if (target) { + target.show(false); + target.sendText(explainPrompt, true); + } + else { + const picked = await vscode.window.showQuickPick(terminals.map(t2 => ({ label: t2.name, terminal: t2 })), { placeHolder: "Pick the Claude terminal to send the explanation request to" }); + if (picked) { + picked.terminal.show(false); + picked.terminal.sendText(explainPrompt, true); + } + } + } + catch (err) { + void vscode.window.showErrorMessage(`Explain change error: ${err instanceof Error ? err.message : String(err)}`); + } + })(); + return; + } + if (msg.command === "refactorInSession" || msg.command === "refactorNewSession") { + const filePath = msg.filePath ?? ""; + const sessRoot = msg.sessionRoot ?? this._workspaceRoot; + const lineCount = msg.lineCount ?? 0; + if (!filePath) + return; + const absPath = path.isAbsolute(filePath) ? filePath : path.join(sessRoot, filePath); + const tier = lineCount >= 1000 ? "CRITICAL — extreme monolith (1000+ lines)" + : lineCount >= 800 ? "HIGH — large file (800–999 lines)" + : "MODERATE — growing file (500–799 lines)"; + const refactorPrompt = `/ab-cleanup + +Refactor this file — ${lineCount} lines flagged ${tier}: + ${absPath} + +Follow every phase of the ab-cleanup protocol. This is a production-grade refactor — Silicon Valley standard. + +═══ PHASE 0 — SAFETY NET (before reading a single line of code) +• Run the existing test suite → record baseline: X passing / Y failing / Z skipped +• grep / find every file that imports or references this module +• List every public export — these are the sacred API contract, do NOT rename without full grep verification of zero callers +• Note any runtime-critical paths (called on startup, hot path, etc.) + +═══ PHASE 1 — AUDIT (read the ENTIRE file, then classify) +• Map every class, function, and responsibility line-by-line +• Identify: God class/component, >3-level nesting, copy-paste blocks, mixed concerns (UI+logic, IO+transform), side effects inside pure functions, magic numbers/strings +• Classify each violation by type: DRY / SRP / coupling / testability / readability / complexity + +═══ PHASE 2 — PLAN ← STOP HERE AND PRESENT BEFORE ANY CODE CHANGES +For every planned extraction, state: + • New filename and target directory + • Lines extracted (source range) + • Why this is safe (callers unaffected, contract unchanged) + • Resulting line count for source file + new file (both must be <300 lines) + • New test(s) required to cover the extracted module + +Murphy's Law check: what is the most fragile thing about this refactor? How will you guard against it? + +DO NOT proceed to Phase 3 until the plan is approved. + +═══ PHASE 3 — EXECUTE (only after plan approval) +• One extraction at a time — tests must pass green after EVERY extraction +• Leave the original file as a thin orchestrator/re-export barrel during transition +• Apply: Single Responsibility, Open/Closed, DRY, Law of Demeter, immutability-first +• Zero magic numbers — extract to named constants with intent-revealing names +• Zero copy-paste — extract to shared utils or helpers +• Every new function: pure where possible, side-effect-free, single responsibility + +═══ PHASE 4 — REGRESSION +• Run the FULL test suite — zero new failures allowed +• For every new module created: write minimum 1 happy-path test + 1 edge/error-case test +• Pre-existing failures: flag as pre-existing, never hide, do NOT count as regressions from this refactor +• Explicitly test the path most likely to break under Murphy's Law + +═══ PHASE 5 — REPORT +• Before/after line counts for every file touched (table format) +• Complete list of new files created +• Any refactors intentionally skipped — reason required (public API contract, legitimate complexity, etc.) +• Public API contract status: UNCHANGED / EXTENDED (never broken)`; + if (msg.command === "refactorInSession") { + // _shell_pid is Claude's PID; terminal.processId is the SHELL's PID (Claude's parent) + const claudePid = msg.shellPid ?? 0; + const sessionNick = msg.sessionNick ?? ""; + const sessRootForTerm = sessRoot; + const terminals = [...vscode.window.terminals]; + void (async () => { + try { + const { execSync: _ex } = require("child_process"); + const termPids = await Promise.all(terminals.map(t => t.processId)); + let target; + // Strategy 1: _shell_pid is Claude's PID → find its parent (the shell terminal) + if (claudePid > 0) { + try { + const ppidStr = _ex(`ps -p ${claudePid} -o ppid= 2>/dev/null`).toString().trim(); + const parentPid = parseInt(ppidStr, 10); + if (parentPid > 0) + target = terminals.find((_, i) => termPids[i] === parentPid); + } + catch { /* fall through */ } + // Also try direct match in case shellPid IS the terminal PID in some setups + if (!target) + target = terminals.find((_, i) => termPids[i] === claudePid); + } + // Strategy 2: cached terminal map from previous focusTerminal calls + if (!target && sessionNick && this._sessionTerminalMap.has(sessionNick)) { + const cachedName = this._sessionTerminalMap.get(sessionNick); + target = terminals.find(t2 => t2.name === cachedName); + } + // Strategy 3: nick in terminal name + if (!target && sessionNick) { + const nickLower = sessionNick.toLowerCase(); + target = terminals.find(t2 => t2.name.toLowerCase().includes(nickLower)); + } + // Strategy 4: CWD match (same as focusTerminal strategy 3) + if (!target && sessRootForTerm) { + const sameCwd = []; + for (const term of terminals) { + try { + const wd = term.shellIntegration?.cwd?.fsPath ?? ""; + if (wd && (wd === sessRootForTerm || wd.startsWith(sessRootForTerm + "/"))) + sameCwd.push(term); + } + catch { /* */ } + } + if (sameCwd.length === 1) + target = sameCwd[0]; + } + if (target) { + target.show(false); + target.sendText(refactorPrompt, true); + } + else { + // Offer a quick-pick as final fallback + const picked = await vscode.window.showQuickPick(terminals.map(t2 => ({ label: t2.name, terminal: t2 })), { placeHolder: "Pick the Claude terminal to send the refactor prompt to" }); + if (picked) { + picked.terminal.show(false); + picked.terminal.sendText(refactorPrompt, true); + } + } + } + catch (err) { + void vscode.window.showErrorMessage(`Refactor error: ${err instanceof Error ? err.message : String(err)}`); + } + })(); + } + else { + // Spawn new Claude terminal with Code Cleanup role + const cwd = sessRoot || this._workspaceRoot; + const terminal = vscode.window.createTerminal({ name: "Claude · Code Cleanup", cwd }); + terminal.show(); + const escaped = refactorPrompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`"); + terminal.sendText(`claude "${escaped}"`, true); + } + return; + } + if (msg.command === "copyPath") { + const relPath = msg.filePath ?? ""; + const sessRoot = msg.sessionRoot ?? this._workspaceRoot; + if (!relPath) + return; + const absPath = path.isAbsolute(relPath) ? relPath : path.join(sessRoot, relPath); + void vscode.env.clipboard.writeText(absPath).then(() => { + void vscode.window.setStatusBarMessage(`Copied: ${absPath}`, 3000); + }); + return; + } + if (msg.command === "focusTerminal") { + const root = msg.sessionRoot ?? ""; + const nick = msg.sessionNick ?? ""; + const shellPid = msg.shellPid ?? 0; + const terminals = [...vscode.window.terminals]; // snapshot — terminals list can change async + void (async () => { + try { + const termPids = await Promise.all(terminals.map(t => t.processId)); + // 1. Exact shell PID match (written by status-bridge hook on every tool call) + if (shellPid > 0) { + const byPid = terminals.find((_, i) => termPids[i] === shellPid); + if (byPid) { + byPid.show(true); + return; + } + // PID no longer matches a live terminal — check if it's a child of any terminal + // (handles cases where claude wraps inside an extra shell layer) + try { + const { execSync: _ex } = await Promise.resolve().then(() => require("child_process")); + for (let i = 0; i < termPids.length; i++) { + const tpid = termPids[i]; + if (!tpid) + continue; + // Get all descendants of this terminal's shell + const children = _ex(`/usr/bin/pgrep -P ${tpid} 2>/dev/null || true`).toString().trim().split("\n").filter(Boolean).map(Number); + if (children.includes(shellPid)) { + terminals[i].show(true); + return; + } + } + } + catch { /* fall through */ } + } + // 2. Nick-based name match — delegate terminals are named "Claude · " + // Regular sessions: try matching nick suffix in terminal name + const nickLower = nick.toLowerCase(); + const byName = terminals.find(t => { + const n = t.name.toLowerCase(); + return n.includes(nickLower) || n.endsWith(nick) || n === `claude · ${nickLower}`; + }); + if (byName) { + byName.show(true); + return; + } + // 3. shellIntegration CWD match — only when exactly one terminal is in this root + if (root) { + const cwdMatches = terminals.filter(t => { + const cwd = t.shellIntegration?.cwd?.fsPath ?? ""; + return cwd && cwd.startsWith(root); + }); + if (cwdMatches.length === 1) { + cwdMatches[0].show(true); + return; + } + } + void vscode.window.showInformationMessage(`⌨ Chat not found for "${nick}". Wait for Claude's next tool call then try again.`); + } + catch (err) { + void vscode.window.showErrorMessage(`focusTerminal error: ${err instanceof Error ? err.message : String(err)}`); + } + })(); + return; + } }, null, this._disposables); this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + vscode.window.onDidCloseTerminal(async (closed) => { + const pid = await closed.processId; + if (!pid) + return; + const sessDir = path.join(os.homedir(), ".agentboard", "sessions"); + try { + for (const fname of fs.readdirSync(sessDir)) { + if (!fname.endsWith(".json")) + continue; + try { + const raw = fs.readFileSync(path.join(sessDir, fname), "utf8"); + const d = JSON.parse(raw); + if (d._shell_pid === pid) { + fs.unlinkSync(path.join(sessDir, fname)); + void this._update(); + } + } + catch { /* skip */ } + } + } + catch { /* dir missing */ } + }, null, this._disposables); } _buildDataSync() { // Always try live.json first — works regardless of which VS Code window is open @@ -558,7 +978,7 @@ class DashboardPanel { return evs; }; const allEvents = getEventsForRoot(this._workspaceRoot); - const lastSkill = lastSkillFromEvents(allEvents); + // lastSkill computed after activeSessions is built so we can filter to active sessions only // Filter events to current session only (prevents stale events from previous /clear sessions bleeding through) const hasSessionIds = allEvents.some(e => e.session_id); const sessionEvents = (hasSessionIds && currentSessionId) @@ -651,8 +1071,8 @@ class DashboardPanel { done: false, }); } - else if (ev.tool === "Agent" && ev.agent) { - const key = ev.agent ?? ""; + else if (ev.tool === "AgentDone") { + const key = ev.label ?? ""; const existing = agentMap.get(key); if (existing) agentMap.set(key, { ...existing, done: true }); @@ -696,9 +1116,11 @@ class DashboardPanel { : allSEvents; const sFileMap = new Map(); for (const ev of [...sEvents].reverse()) { - if (!ev.file && !ev.cmd) + // Skill events may only have ev.skill (not ev.file) — normalize to displayable key + const skillName = ev.tool === 'Skill' ? (ev.skill || ev.file || "") : ""; + if (!ev.file && !ev.cmd && !skillName) continue; - const k = ev.file ?? `$ ${(ev.cmd ?? "").slice(0, 60)}`; + const k = skillName ? `/${skillName}` : (ev.file ?? `$ ${(ev.cmd ?? "").slice(0, 60)}`); const ex = sFileMap.get(k); if (!ex || ev.ts > ex.lastTs) sFileMap.set(k, { tool: ev.tool, count: (ex?.count ?? 0) + 1, lastTs: ev.ts }); @@ -738,6 +1160,85 @@ class DashboardPanel { } } catch { /* git unavailable or repo not found — skip diff stats */ } + // Enrich file entries with current line count (cached, 60 s TTL) + const LINE_COUNT_TTL = 60000; + for (const entry of sActivity) { + if (entry.file.startsWith("$ ") || !sRoot) + continue; + const absFile = path.join(sRoot, entry.file); + try { + const cached = this._lineCountCache.get(absFile); + if (cached && (Date.now() - cached.ts) < LINE_COUNT_TTL) { + entry.lineCount = cached.count; + } + else { + const lines = fs.readFileSync(absFile, "utf8").split("\n").length; + this._lineCountCache.set(absFile, { ts: Date.now(), count: lines }); + entry.lineCount = lines; + } + } + catch { /* file may not exist yet */ } + } + // Mark files that have committed changes on this branch vs develop/main merge-base + try { + const COMMITTED_TTL = 30000; + const cacheKey = sRoot; + const cachedC = this._branchCommittedCache.get(cacheKey); + let committedFiles; + if (cachedC && (Date.now() - cachedC.ts) < COMMITTED_TTL) { + committedFiles = cachedC.files; + } + else { + committedFiles = new Set(); + // Find merge-base with develop, then main, then fall back to HEAD~1 + let mergeBase = ""; + for (const base of ["origin/develop", "origin/main", "HEAD~1"]) { + try { + mergeBase = (0, child_process_1.execSync)(`git merge-base HEAD ${base}`, { cwd: sRoot, timeout: 3000, encoding: "utf8" }).trim(); + if (mergeBase) + break; + } + catch { /* try next */ } + } + if (mergeBase) { + const nameOnly = (0, child_process_1.execSync)(`git diff --name-only ${mergeBase}..HEAD`, { cwd: sRoot, timeout: 3000, encoding: "utf8" }); + for (const line of nameOnly.split("\n")) { + const f2 = line.trim(); + if (f2) + committedFiles.add(f2); + } + } + this._branchCommittedCache.set(cacheKey, { ts: Date.now(), files: committedFiles }); + } + for (const entry of sActivity) { + if (entry.tool === "Edit" || entry.tool === "Write" || entry.tool === "MultiEdit") { + entry.committed = committedFiles.has(entry.file); + } + } + } + catch { /* git unavailable */ } + } + // Detect new/deleted files via git status --porcelain + if (sRoot) { + try { + const statusOut = (0, child_process_1.execSync)(`git -C "${sRoot}" status --porcelain 2>/dev/null`, { timeout: 3000, encoding: "utf8" }); + const statusMap = new Map(); + for (const line of statusOut.split("\n")) { + if (line.length < 4) + continue; + const xy = line.slice(0, 2); + const fpath = line.slice(3).trim().replace(/^"(.*)"$/, "$1"); // git quotes paths with spaces + statusMap.set(fpath, xy); + } + for (const entry of sActivity) { + const xy = statusMap.get(entry.file) ?? statusMap.get(entry.file.replace(/\\/g, "/")) ?? ""; + if (xy === "??" || xy[0] === "A" || xy[1] === "A") + entry.isNew = true; + else if (xy[0] === "D" || xy[1] === "D") + entry.isDeleted = true; + } + } + catch { /* git unavailable */ } } // Skip ghost sessions: no tool events AND session started >15 min ago // Use startedAt age (not lastUpdated) so status-bridge pings don't keep ghosts alive @@ -760,8 +1261,8 @@ class DashboardPanel { ts: ev.ts, done: false, }); } - else if (ev.tool === "Agent" && ev.agent) { - const k = ev.agent ?? ""; + else if (ev.tool === "AgentDone") { + const k = ev.label ?? ""; const ex = sAgentMap.get(k); if (ex) sAgentMap.set(k, { ...ex, done: true }); @@ -838,7 +1339,9 @@ class DashboardPanel { cost: costUsd > 0 ? `$${costUsd.toFixed(3)}` : "", branch: ctx.branch || "", root: sRoot, + shellPid: s._shell_pid || 0, projectName: sRoot ? path.basename(sRoot) : "", + sessionLastSkill: "", sessionLastRole: "", startedAt: sStartedAt, lastUpdated, ageSeconds: Math.floor(ageMs / 1000), @@ -889,29 +1392,38 @@ class DashboardPanel { activeSessions.push(...Array.from(slotMap.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt))); } catch { /* sessions dir doesn't exist yet */ } - // Build skill/role usage map from session events + // lastSkill: only from currently active sessions (avoids stale closed-session data in footer) + const activeSessionIds = new Set(activeSessions.map(s => s.sessionId)); function sessionNick(id) { - const ADJ = ['bold', 'calm', 'swift', 'bright', 'deep', 'sharp', 'keen', 'dark', 'wild', 'quiet', 'brave', 'cool', 'warm', 'soft', 'fast', 'wise', 'pure', 'deft', 'lean', 'teal', 'grey', 'sage']; - const NON = ['falcon', 'tiger', 'wolf', 'eagle', 'raven', 'fox', 'bear', 'hawk', 'lynx', 'crane', 'otter', 'pike', 'heron', 'wren', 'viper', 'bison', 'moose', 'ibis', 'kite', 'wasp', 'colt', 'finch']; + const ADJ = ['bold', 'calm', 'swift', 'bright', 'sharp', 'keen', 'wild', 'quiet', 'brave', 'cool', 'warm', 'soft', 'fast', 'wise', 'pure', 'deft', 'lean', 'sage', 'red', 'blue', 'gold', 'jade', 'iron', 'amber', 'violet', 'azure', 'coral', 'frost', 'storm', 'sand', 'ember', 'cedar', 'steel', 'nova', 'oak', 'ivy', 'clay', 'moss', 'dawn', 'rust']; + const NON = ['falcon', 'tiger', 'wolf', 'eagle', 'raven', 'fox', 'bear', 'hawk', 'lynx', 'crane', 'otter', 'pike', 'heron', 'wren', 'viper', 'bison', 'moose', 'ibis', 'kite', 'wasp', 'colt', 'finch', 'puma', 'cobra', 'gecko', 'quail', 'trout', 'mink', 'stork', 'stoat', 'dingo', 'snipe', 'marten', 'condor', 'osprey', 'ferret', 'oriole', 'magpie', 'jaguar', 'marlin']; let h = 0; for (let i = 0; i < id.length; i++) h = (Math.imul(h, 31) + id.charCodeAt(i)) >>> 0; return ADJ[h % ADJ.length] + '-' + NON[(h >>> 8) % NON.length]; } + const activeEventsForSkill = allEvents.filter(e => !e.session_id || activeSessionIds.has(e.session_id)); + const { skill: lastSkill, sessionId: lastSkillSessionId } = lastSkillFromEvents(activeEventsForSkill); + const lastSkillSession = (lastSkillSessionId && activeSessionIds.has(lastSkillSessionId)) ? sessionNick(lastSkillSessionId) : ""; const skillUsage = new Map(); const roleUsage = new Map(); + const sessionLastSkillMap = new Map(); + const sessionLastRoleMap = new Map(); for (const sess of activeSessions) { if (!sess.root) continue; const evs = getEventsForRoot(sess.root).filter(e => e.session_id === sess.sessionId); const nick = sessionNick(sess.sessionId); for (const ev of evs) { - if (ev.tool === 'Skill' && ev.skill) { - const sk = ev.skill; - if (!skillUsage.has(sk)) - skillUsage.set(sk, []); - if (!skillUsage.get(sk).includes(nick)) - skillUsage.get(sk).push(nick); + if (ev.tool === 'Skill') { + const sk = ev.skill || ev.file || ""; + if (sk) { + if (!skillUsage.has(sk)) + skillUsage.set(sk, []); + if (!skillUsage.get(sk).includes(nick)) + skillUsage.get(sk).push(nick); + sessionLastSkillMap.set(sess.sessionId, sk); // last wins = most recent + } } // RoleAdopt: main session read a role file — slug-keyed if (ev.tool === 'RoleAdopt' && ev.role) { @@ -920,6 +1432,7 @@ class DashboardPanel { roleUsage.set(ro, []); if (!roleUsage.get(ro).includes(nick)) roleUsage.get(ro).push(nick); + sessionLastRoleMap.set(sess.sessionId, ro); } // AgentStart with role label (sub-agents dispatched with role:) if (ev.tool === 'AgentStart' && ev.role) { @@ -928,8 +1441,12 @@ class DashboardPanel { roleUsage.set(ro, []); if (!roleUsage.get(ro).includes(nick)) roleUsage.get(ro).push(nick); + if (!sessionLastRoleMap.has(sess.sessionId)) + sessionLastRoleMap.set(sess.sessionId, ro); } } + sess.sessionLastSkill = sessionLastSkillMap.get(sess.sessionId) ?? ""; + sess.sessionLastRole = sessionLastRoleMap.get(sess.sessionId) ?? ""; } const skillsWithUsage = skills.map(s => ({ ...s, usedBy: skillUsage.get(s.name) ?? skillUsage.get(s.slug ?? '') ?? [] })); // Match roles by slug first (RoleAdopt events use slug), then display name (AgentStart labels) @@ -941,7 +1458,7 @@ class DashboardPanel { }); return { type: "update", - hasLive, model, cost, sessionTime, activeStream, streamDesc, activeRole, lastSkill, + hasLive, model, cost, sessionTime, activeStream, streamDesc, activeRole, lastSkill, lastSkillSession, ctxPct, branch, cpRunning: false, sessions: 0, totalUniqueFiles, activeSessions, activeWorkflow, @@ -955,9 +1472,66 @@ class DashboardPanel { projectName: path.basename(this._workspaceRoot), }; } + _handleDelegateFile() { + const delegateFile = path.join(os.homedir(), ".agentboard", "delegate.json"); + if (!fs.existsSync(delegateFile)) + return; + let raw; + try { + raw = fs.readFileSync(delegateFile, "utf8"); + fs.unlinkSync(delegateFile); // delete first — prevent duplicate opens on next tick + } + catch { + return; + } + try { + const d = JSON.parse(raw); + if (!d.role || !d.task) + return; + // Dedup: ignore if same role+task was already handled within 60 seconds + const dedupeKey = `${d.role}|${d.task}`; + if (dedupeKey === this._lastDelegateKey && (Date.now() - this._lastDelegateTs) < 60000) + return; + this._lastDelegateKey = dedupeKey; + this._lastDelegateTs = Date.now(); + const roles = readRoles(d.root ?? this._workspaceRoot); + const roleItem = roles.find(r => r.slug === d.role); + const roleName = roleItem?.name ?? d.role; + const lines = [ + `Adopt the ${roleName} role for this session.`, + `Read .platform/roles/${d.role}.md for your full protocol, mission, and responsibilities.`, + ]; + if (d.project || d.branch) { + const from = [d.project, d.branch ? `branch: ${d.branch}` : ""].filter(Boolean).join(" — "); + lines.push(`\nHandoff from: ${from}`); + } + if (d.context) + lines.push(d.context); + lines.push(`\nYour task: ${d.task}`); + lines.push(`\nAsk me 2–3 focused intake questions if anything needs clarification, then begin.`); + const prompt = lines.join("\n"); + const escaped = prompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`"); + const cwd = d.root && fs.existsSync(d.root) ? d.root : this._workspaceRoot; + const termName = `Claude · ${roleName}`; + const terminal = vscode.window.createTerminal({ name: termName, cwd }); + terminal.show(); + terminal.sendText(`claude "${escaped}"`, true); + this._sessionTerminalMap.set(d.role, termName); + } + catch { /* malformed delegate.json — silently ignore */ } + } + _deleteSessionFile(sessionId) { + const sessDir = path.join(os.homedir(), ".agentboard", "sessions"); + const f = path.join(sessDir, `${sessionId}.json`); + try { + fs.unlinkSync(f); + } + catch { /* already gone */ } + } async _update() { if (!this._initialized) return; + this._handleDelegateFile(); const data = this._buildDataSync(); // HTTP calls for sessions/worktrees: skip entirely when server is consistently absent (backoff) let sessions = []; @@ -1008,8 +1582,9 @@ class DashboardPanel { body{background:var(--vscode-editor-background);color:var(--vscode-editor-foreground);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;height:100vh;display:flex;flex-direction:column;overflow:hidden} #hdr{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--vscode-panel-border);flex-shrink:0} .logo{color:#4a9eff;font-weight:700;letter-spacing:.08em;font-size:11px}.sep{opacity:.25}.proj{opacity:.65;font-size:12px}.br{opacity:.4;font-size:11px} -.rbtn{margin-left:auto;background:transparent;border:1px solid var(--vscode-panel-border);color:inherit;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px} +.rbtn{margin-left:auto;background:transparent;border:1px solid var(--vscode-panel-border);color:inherit;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px;transition:opacity .1s} .rbtn:hover{background:var(--vscode-list-hoverBackground)} +.rbtn:active{opacity:.5} .tabs{display:flex;border-bottom:1px solid var(--vscode-panel-border);flex-shrink:0;padding:0 14px} .tab{padding:5px 12px;font-size:12px;cursor:pointer;border:none;border-bottom:2px solid transparent;opacity:.45;transition:all .15s;background:none;color:inherit} .tab.on{opacity:1;border-bottom-color:#4a9eff;color:#4a9eff} @@ -1053,8 +1628,8 @@ body{background:var(--vscode-editor-background);color:var(--vscode-editor-foregr .fa{display:grid;grid-template-columns:auto 1fr;gap:0 10px;padding:4px 0;border-bottom:1px solid rgba(128,128,128,.07);font-size:12px} .fa:last-child{border-bottom:none} .fa-icon{opacity:.45;font-size:11px;text-align:center;width:14px;padding-top:2px} -.fa-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 6px;min-width:0} -.fa-file{font-family:var(--vscode-editor-font-family,'monospace');word-break:break-all;line-height:1.5} +.fa-body{display:flex;flex-wrap:nowrap;align-items:baseline;gap:0 6px;min-width:0;overflow:hidden} +.fa-file{font-family:var(--vscode-editor-font-family,'monospace');overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0} .fa-cnt{font-size:10px;opacity:.3;white-space:nowrap;flex-shrink:0} .fa-t{font-size:10px;opacity:.35;white-space:nowrap;flex-shrink:0} /* streams */ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 9d94a05..795d756 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,7 +2,7 @@ "name": "agentboard", "displayName": "Agentboard", "description": "Live agentboard session status — streams, agents, cost, risk", - "version": "2.2.0", + "version": "2.3.1", "publisher": "da0101", "license": "MIT", "engines": { diff --git a/extensions/vscode/src/dashboardPanel.ts b/extensions/vscode/src/dashboardPanel.ts index c139627..6666dde 100644 --- a/extensions/vscode/src/dashboardPanel.ts +++ b/extensions/vscode/src/dashboardPanel.ts @@ -25,7 +25,7 @@ const AB_CLI_COMMANDS: CatalogItem[] = [ interface ActivityEvent { ts: string; tool: string; stream: string; file?: string; cmd?: string; agent?: string; skill?: string; hook_event_name?: string; session_id?: string; } -interface CatalogItem { name: string; slug?: string; description: string; fullDescription?: string; usedBy?: string[] } +interface CatalogItem { name: string; slug?: string; description: string; fullDescription?: string; usedBy?: string[]; linkedSkills?: string[] } interface StreamEntry { slug: string; type: string; status: string; role: string; objective: string; nextAction: string; branch: string; @@ -132,6 +132,19 @@ function readSkills(root: string): CatalogItem[] { function readRoles(root: string): CatalogItem[] { const dir = path.join(root, ".platform", "roles"); try { + // Parse explicit pairs from INDEX.md: `role-slug`+ab-skill + const indexPairs = new Map(); + try { + const indexContent = fs.readFileSync(path.join(dir, "INDEX.md"), "utf8"); + const pairRe = /`([a-z][a-z-]+)`\+([a-z][a-z-]+)/g; + let m; + while ((m = pairRe.exec(indexContent)) !== null) { + const [, roleSlug, skillSlug] = m; + if (!indexPairs.has(roleSlug)) indexPairs.set(roleSlug, []); + indexPairs.get(roleSlug)!.push(skillSlug); + } + } catch { /* no INDEX.md */ } + return fs.readdirSync(dir).filter(f => f.endsWith(".md") && f !== "INDEX.md").flatMap(f => { try { const content = fs.readFileSync(path.join(dir, f), "utf8"); @@ -139,7 +152,12 @@ function readRoles(root: string): CatalogItem[] { const slug = path.basename(f, ".md"); const afterFm = content.replace(/^---[\s\S]*?---\n?/, '').trim(); const fullDescription = extractProse(afterFm); - return [{ name: fm.name ?? fm.slug ?? slug, slug, description: fm.mission ?? fm.description ?? fm.objective ?? '', fullDescription }]; + // Merge INDEX.md pairs with ab-* mentions found in the role file body + const linked = new Set(indexPairs.get(slug) ?? []); + const bodyMatches = afterFm.match(/\bab-[a-z][a-z-]+/g) ?? []; + for (const s of bodyMatches) linked.add(s); + const linkedSkills = [...linked]; + return [{ name: fm.name ?? fm.slug ?? slug, slug, description: fm.mission ?? fm.description ?? fm.objective ?? '', fullDescription, linkedSkills }]; } catch { return []; } }); } catch { return []; } @@ -310,8 +328,9 @@ function readWorkflowPlan(root: string): WorkflowPlan | null { function lastSkillFromEvents(events: ActivityEvent[]): { skill: string; sessionId: string } { for (const e of events) { - if (e.tool === "Skill" && (e as {skill?: string}).skill) - return { skill: (e as {skill?: string}).skill!, sessionId: e.session_id ?? "" }; + if (e.tool !== "Skill") continue; + const sk = (e as {skill?: string}).skill || e.file || ""; + if (sk) return { skill: sk, sessionId: e.session_id ?? "" }; } return { skill: "", sessionId: "" }; } @@ -356,8 +375,14 @@ export class DashboardPanel { // Numstat cache: avoid blocking the extension host on every tick (30 s TTL per root) private _numstatCache = new Map }>(); private _lineCountCache = new Map(); + // Branch-committed cache: files changed vs merge-base with develop/main (30 s TTL per root) + private _branchCommittedCache = new Map }>(); // HTTP backoff: slow down if server consistently absent private _httpFailStreak = 0; + private _lastDelegateKey = ""; // "|" dedup + private _lastDelegateTs = 0; // epoch ms of last handled delegate + // nick → terminal name cache so focusTerminal can match by session nick + private _sessionTerminalMap = new Map(); // nick → terminal.name static createOrShow(workspaceRoot: string, extensionUri?: vscode.Uri): void { if (extensionUri) DashboardPanel.extensionUri = extensionUri; @@ -418,23 +443,273 @@ export class DashboardPanel { return; } if (msg.command === "openDiff") { - const relPath = (msg as {filePath?: string; sessionRoot?: string}).filePath ?? ""; + const relPath = (msg as {filePath?: string; sessionRoot?: string; isNew?: boolean}).filePath ?? ""; const sessRoot = (msg as {filePath?: string; sessionRoot?: string}).sessionRoot ?? this._workspaceRoot; + const isNewFile = (msg as {isNew?: boolean}).isNew ?? false; if (!relPath) return; - const absPath = path.isAbsolute(relPath) ? relPath : path.join(sessRoot, relPath); + // Resolve absolute path — try sessRoot first, then workspaceRoot + let absPath = path.isAbsolute(relPath) ? relPath : path.join(sessRoot, relPath); + if (!fs.existsSync(absPath)) { + const alt = path.join(this._workspaceRoot, relPath); + if (fs.existsSync(alt)) absPath = alt; + } const rightUri = vscode.Uri.file(absPath); - // Use VS Code git extension's URI scheme to show HEAD version on the left + // New/untracked files have no HEAD version — open the file directly + if (isNewFile || !fs.existsSync(absPath)) { + if (fs.existsSync(absPath)) { + void vscode.window.showTextDocument(rightUri); + } else { + void vscode.window.showWarningMessage(`File not found: ${relPath}`); + } + return; + } + // Check if file is tracked by git before attempting diff + try { + const { execSync: _ex } = require("child_process") as typeof import("child_process"); + const status = _ex(`git -C "${sessRoot}" status --porcelain -- "${relPath}" 2>/dev/null`).toString().trim(); + if (status.startsWith("??")) { + // Untracked — no HEAD version, just open the file + void vscode.window.showTextDocument(rightUri); + return; + } + } catch { /* fall through to diff attempt */ } const gitUri = rightUri.with({ scheme: "git", query: JSON.stringify({ path: absPath, ref: "HEAD" }), }); const fileName = path.basename(absPath); void vscode.commands.executeCommand("vscode.diff", gitUri, rightUri, `${fileName}: HEAD ↔ Working Tree`).then(undefined, () => { - // Fallback if file is new (not in HEAD) — just open the file void vscode.window.showTextDocument(rightUri); }); return; } + if (msg.command === "closeSession") { + const sessionId = (msg as {sessionId?: string}).sessionId ?? ""; + if (!sessionId) return; + this._deleteSessionFile(sessionId); + void this._update(); + return; + } + if (msg.command === "launchRole") { + const slug = (msg as {slug?: string; name?: string}).slug ?? ""; + const name = (msg as {slug?: string; name?: string}).name ?? slug; + if (!slug) return; + const terminal = vscode.window.createTerminal({ name: `Claude · ${name}`, cwd: this._workspaceRoot }); + terminal.show(); + const prompt = `Adopt the ${name} role for this session. Read .platform/roles/${slug}.md for your full protocol, mission, and responsibilities. Ask me 2–3 focused intake questions to understand what I need, then begin working.`; + const escaped = prompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`"); + terminal.sendText(`claude "${escaped}"`, true); + return; + } + if (msg.command === "explainChange") { + const filePath = (msg as {filePath?: string}).filePath ?? ""; + const sessRoot = (msg as {sessionRoot?: string}).sessionRoot ?? this._workspaceRoot; + const added = (msg as {added?: number}).added ?? 0; + const deleted = (msg as {deleted?: number}).deleted ?? 0; + const totalChanged = (msg as {totalChanged?: number}).totalChanged ?? 0; + const claudePid = (msg as {shellPid?: number}).shellPid ?? 0; + const sessionNick = (msg as {sessionNick?: string}).sessionNick ?? ""; + if (!filePath) return; + const absPath = path.isAbsolute(filePath) ? filePath : path.join(sessRoot, filePath); + const diffStat = (added ? `+${added}` : "") + (added && deleted ? " / " : "") + (deleted ? `-${deleted}` : ""); + const explainPrompt = `/ab-review + +A reviewer is auditing \`${absPath}\` and sees ⚠ **${totalChanged} lines changed** (${diffStat}). + +You made these changes — walk through your decisions clearly and directly. No preamble. Assume the reviewer is a senior engineer. + +═══ 1. PROBLEM & APPROACH +What problem were you solving in this file specifically, and why did you choose this approach over the alternatives you considered? + +═══ 2. KEY CHANGES — section by section +For each significant block you added or rewrote: +• What was there before (brief) +• What you changed it to +• Why this is strictly better + +═══ 3. WHAT WAS REMOVED AND WHY +For every significant deletion: what did you remove, why was it wrong/redundant/dead, and what (if anything) replaces it? + +═══ 4. DESIGN DECISIONS +Any non-obvious architectural choices — naming, structure, data flow, abstraction boundaries, dependency direction. State the reasoning and the tradeoff you accepted. + +═══ 5. RISK SURFACE +What is the most fragile thing about these changes? Any edge cases, regressions, or coupling risks introduced? What guards did you put in place? + +═══ 6. WHAT TO WATCH NEXT +Anything in this file that is still wrong, incomplete, or will need follow-up attention?`; + + const terminals = [...vscode.window.terminals]; + void (async () => { try { + const { execSync: _ex } = require("child_process") as typeof import("child_process"); + const termPids = await Promise.all(terminals.map(t => t.processId)); + let target: vscode.Terminal | undefined; + if (claudePid > 0) { + try { + const ppidStr = _ex(`ps -p ${claudePid} -o ppid= 2>/dev/null`).toString().trim(); + const parentPid = parseInt(ppidStr, 10); + if (parentPid > 0) target = terminals.find((_, i) => termPids[i] === parentPid); + } catch { /* fall through */ } + if (!target) target = terminals.find((_, i) => termPids[i] === claudePid); + } + if (!target && sessionNick && this._sessionTerminalMap.has(sessionNick)) { + const cachedName = this._sessionTerminalMap.get(sessionNick)!; + target = terminals.find(t2 => t2.name === cachedName); + } + if (!target && sessionNick) { + const nickLower = sessionNick.toLowerCase(); + target = terminals.find(t2 => t2.name.toLowerCase().includes(nickLower)); + } + if (!target && sessRoot) { + const sameCwd: vscode.Terminal[] = []; + for (const term of terminals) { + try { + const wd = (term as unknown as { shellIntegration?: { cwd?: vscode.Uri } }).shellIntegration?.cwd?.fsPath ?? ""; + if (wd && (wd === sessRoot || wd.startsWith(sessRoot + "/"))) sameCwd.push(term); + } catch { /* */ } + } + if (sameCwd.length === 1) target = sameCwd[0]; + } + if (target) { + target.show(false); + target.sendText(explainPrompt, true); + } else { + const picked = await vscode.window.showQuickPick( + terminals.map(t2 => ({ label: t2.name, terminal: t2 })), + { placeHolder: "Pick the Claude terminal to send the explanation request to" } + ); + if (picked) { picked.terminal.show(false); picked.terminal.sendText(explainPrompt, true); } + } + } catch (err) { + void vscode.window.showErrorMessage(`Explain change error: ${err instanceof Error ? err.message : String(err)}`); + } })(); + return; + } + if (msg.command === "refactorInSession" || msg.command === "refactorNewSession") { + const filePath = (msg as {filePath?: string}).filePath ?? ""; + const sessRoot = (msg as {sessionRoot?: string}).sessionRoot ?? this._workspaceRoot; + const lineCount = (msg as {lineCount?: number}).lineCount ?? 0; + if (!filePath) return; + const absPath = path.isAbsolute(filePath) ? filePath : path.join(sessRoot, filePath); + const tier = lineCount >= 1000 ? "CRITICAL — extreme monolith (1000+ lines)" + : lineCount >= 800 ? "HIGH — large file (800–999 lines)" + : "MODERATE — growing file (500–799 lines)"; + const refactorPrompt = `/ab-cleanup + +Refactor this file — ${lineCount} lines flagged ${tier}: + ${absPath} + +Follow every phase of the ab-cleanup protocol. This is a production-grade refactor — Silicon Valley standard. + +═══ PHASE 0 — SAFETY NET (before reading a single line of code) +• Run the existing test suite → record baseline: X passing / Y failing / Z skipped +• grep / find every file that imports or references this module +• List every public export — these are the sacred API contract, do NOT rename without full grep verification of zero callers +• Note any runtime-critical paths (called on startup, hot path, etc.) + +═══ PHASE 1 — AUDIT (read the ENTIRE file, then classify) +• Map every class, function, and responsibility line-by-line +• Identify: God class/component, >3-level nesting, copy-paste blocks, mixed concerns (UI+logic, IO+transform), side effects inside pure functions, magic numbers/strings +• Classify each violation by type: DRY / SRP / coupling / testability / readability / complexity + +═══ PHASE 2 — PLAN ← STOP HERE AND PRESENT BEFORE ANY CODE CHANGES +For every planned extraction, state: + • New filename and target directory + • Lines extracted (source range) + • Why this is safe (callers unaffected, contract unchanged) + • Resulting line count for source file + new file (both must be <300 lines) + • New test(s) required to cover the extracted module + +Murphy's Law check: what is the most fragile thing about this refactor? How will you guard against it? + +DO NOT proceed to Phase 3 until the plan is approved. + +═══ PHASE 3 — EXECUTE (only after plan approval) +• One extraction at a time — tests must pass green after EVERY extraction +• Leave the original file as a thin orchestrator/re-export barrel during transition +• Apply: Single Responsibility, Open/Closed, DRY, Law of Demeter, immutability-first +• Zero magic numbers — extract to named constants with intent-revealing names +• Zero copy-paste — extract to shared utils or helpers +• Every new function: pure where possible, side-effect-free, single responsibility + +═══ PHASE 4 — REGRESSION +• Run the FULL test suite — zero new failures allowed +• For every new module created: write minimum 1 happy-path test + 1 edge/error-case test +• Pre-existing failures: flag as pre-existing, never hide, do NOT count as regressions from this refactor +• Explicitly test the path most likely to break under Murphy's Law + +═══ PHASE 5 — REPORT +• Before/after line counts for every file touched (table format) +• Complete list of new files created +• Any refactors intentionally skipped — reason required (public API contract, legitimate complexity, etc.) +• Public API contract status: UNCHANGED / EXTENDED (never broken)`; + + if (msg.command === "refactorInSession") { + // _shell_pid is Claude's PID; terminal.processId is the SHELL's PID (Claude's parent) + const claudePid = (msg as {shellPid?: number}).shellPid ?? 0; + const sessionNick = (msg as {sessionNick?: string}).sessionNick ?? ""; + const sessRootForTerm = sessRoot; + const terminals = [...vscode.window.terminals]; + void (async () => { try { + const { execSync: _ex } = require("child_process") as typeof import("child_process"); + const termPids = await Promise.all(terminals.map(t => t.processId)); + let target: vscode.Terminal | undefined; + + // Strategy 1: _shell_pid is Claude's PID → find its parent (the shell terminal) + if (claudePid > 0) { + try { + const ppidStr = _ex(`ps -p ${claudePid} -o ppid= 2>/dev/null`).toString().trim(); + const parentPid = parseInt(ppidStr, 10); + if (parentPid > 0) target = terminals.find((_, i) => termPids[i] === parentPid); + } catch { /* fall through */ } + // Also try direct match in case shellPid IS the terminal PID in some setups + if (!target) target = terminals.find((_, i) => termPids[i] === claudePid); + } + // Strategy 2: cached terminal map from previous focusTerminal calls + if (!target && sessionNick && this._sessionTerminalMap.has(sessionNick)) { + const cachedName = this._sessionTerminalMap.get(sessionNick)!; + target = terminals.find(t2 => t2.name === cachedName); + } + // Strategy 3: nick in terminal name + if (!target && sessionNick) { + const nickLower = sessionNick.toLowerCase(); + target = terminals.find(t2 => t2.name.toLowerCase().includes(nickLower)); + } + // Strategy 4: CWD match (same as focusTerminal strategy 3) + if (!target && sessRootForTerm) { + const sameCwd: vscode.Terminal[] = []; + for (const term of terminals) { + try { + const wd = (term as unknown as { shellIntegration?: { cwd?: vscode.Uri } }).shellIntegration?.cwd?.fsPath ?? ""; + if (wd && (wd === sessRootForTerm || wd.startsWith(sessRootForTerm + "/"))) sameCwd.push(term); + } catch { /* */ } + } + if (sameCwd.length === 1) target = sameCwd[0]; + } + if (target) { + target.show(false); + target.sendText(refactorPrompt, true); + } else { + // Offer a quick-pick as final fallback + const picked = await vscode.window.showQuickPick( + terminals.map(t2 => ({ label: t2.name, terminal: t2 })), + { placeHolder: "Pick the Claude terminal to send the refactor prompt to" } + ); + if (picked) { picked.terminal.show(false); picked.terminal.sendText(refactorPrompt, true); } + } + } catch (err) { + void vscode.window.showErrorMessage(`Refactor error: ${err instanceof Error ? err.message : String(err)}`); + } })(); + } else { + // Spawn new Claude terminal with Code Cleanup role + const cwd = sessRoot || this._workspaceRoot; + const terminal = vscode.window.createTerminal({ name: "Claude · Code Cleanup", cwd }); + terminal.show(); + const escaped = refactorPrompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`"); + terminal.sendText(`claude "${escaped}"`, true); + } + return; + } if (msg.command === "copyPath") { const relPath = (msg as {filePath?: string; sessionRoot?: string}).filePath ?? ""; const sessRoot = (msg as {filePath?: string; sessionRoot?: string}).sessionRoot ?? this._workspaceRoot; @@ -449,74 +724,38 @@ export class DashboardPanel { const root = (msg as {sessionRoot?: string; sessionNick?: string; shellPid?: number}).sessionRoot ?? ""; const nick = (msg as {sessionRoot?: string; sessionNick?: string; shellPid?: number}).sessionNick ?? ""; const shellPid = (msg as {sessionRoot?: string; sessionNick?: string; shellPid?: number}).shellPid ?? 0; - const terminals = vscode.window.terminals; - const sessionStartedAt = (msg as {sessionRoot?: string; sessionNick?: string; shellPid?: number; sessionStartedAt?: string}).sessionStartedAt ?? ""; - void (async () => { - const { execSync: _ex } = await import("child_process"); + const terminals = [...vscode.window.terminals]; // snapshot — terminals list can change async + void (async () => { try { const termPids = await Promise.all(terminals.map(t => t.processId)); - // 1. Stored shell PID from status-bridge — most reliable, try first + // 1. Exact shell PID match (written by status-bridge hook on every tool call) if (shellPid > 0) { const byPid = terminals.find((_, i) => termPids[i] === shellPid); if (byPid) { byPid.show(true); return; } - } - - // 2. Live process-tree match. - // macOS note: claude binary shows as version number (e.g. "2.1.181") in ps COMM, - // so we use pgrep -x claude (matches the real binary name) then query each PID - // individually. etime is in "[[DD-]HH:]MM:SS" format on macOS, not decimal seconds. - const parseEtime = (s: string): number => { - s = s.trim(); - let days = 0; - if (s.includes("-")) { const [d, rest] = s.split("-"); days = parseInt(d, 10); s = rest; } - const parts = s.split(":").map(Number); - const secs = parts.length === 3 ? parts[0]*3600 + parts[1]*60 + parts[2] : parts[0]*60 + (parts[1] ?? 0); - return days * 86400 + secs; - }; - const findTerminalByProcessTree = (): vscode.Terminal | undefined => { + // PID no longer matches a live terminal — check if it's a child of any terminal + // (handles cases where claude wraps inside an extra shell layer) try { - const rawPids = _ex("/usr/bin/pgrep -x claude 2>/dev/null || true").toString().trim().split("\n").filter(Boolean).map(Number).filter(n => !isNaN(n) && n > 0); - if (rawPids.length === 0) return undefined; - - const sessionAgeS = sessionStartedAt ? Math.floor((Date.now() - new Date(sessionStartedAt).getTime()) / 1000) : -1; - interface Match { ageS: number; termIdx: number } - const matches: Match[] = []; - - for (const cpid of rawPids) { - try { - // Check cwd - const cwd = _ex(`/usr/sbin/lsof -p ${cpid} 2>/dev/null | /usr/bin/awk '$4 == "cwd" { print $NF; exit }'`).toString().trim(); - if (!root || !cwd || !cwd.startsWith(root)) continue; - // Get process elapsed time for disambiguation - const etimeStr = _ex(`/bin/ps -p ${cpid} -o etime= 2>/dev/null`).toString(); - const ageS = parseEtime(etimeStr); - // Walk ppid chain (up to 4 hops) to find the VS Code terminal shell - let cur = cpid; - let termIdx = -1; - for (let d = 0; d < 4; d++) { - const ppidStr = _ex(`/bin/ps -p ${cur} -o ppid= 2>/dev/null`).toString().trim(); - const ppid = parseInt(ppidStr, 10); - if (!ppid || ppid === cur) break; - const idx = termPids.findIndex(p => p === ppid); - if (idx >= 0) { termIdx = idx; break; } - cur = ppid; - } - if (termIdx >= 0) matches.push({ ageS, termIdx }); - } catch { continue; } + const { execSync: _ex } = await import("child_process"); + for (let i = 0; i < termPids.length; i++) { + const tpid = termPids[i]; + if (!tpid) continue; + // Get all descendants of this terminal's shell + const children = _ex(`/usr/bin/pgrep -P ${tpid} 2>/dev/null || true`).toString().trim().split("\n").filter(Boolean).map(Number); + if (children.includes(shellPid)) { terminals[i].show(true); return; } } + } catch { /* fall through */ } + } - if (matches.length === 0) return undefined; - if (matches.length === 1) return terminals[matches[0].termIdx]; - // Multiple claude processes in same root — pick closest by process age - if (sessionAgeS >= 0) matches.sort((a, b) => Math.abs(a.ageS - sessionAgeS) - Math.abs(b.ageS - sessionAgeS)); - return terminals[matches[0].termIdx]; - } catch { return undefined; } - }; - - const byTree = findTerminalByProcessTree(); - if (byTree) { byTree.show(true); return; } + // 2. Nick-based name match — delegate terminals are named "Claude · " + // Regular sessions: try matching nick suffix in terminal name + const nickLower = nick.toLowerCase(); + const byName = terminals.find(t => { + const n = t.name.toLowerCase(); + return n.includes(nickLower) || n.endsWith(nick) || n === `claude · ${nickLower}`; + }); + if (byName) { byName.show(true); return; } - // 3. Unambiguous CWD match (only if exactly one terminal is in this root) + // 3. shellIntegration CWD match — only when exactly one terminal is in this root if (root) { const cwdMatches = terminals.filter(t => { const cwd = (t.shellIntegration as { cwd?: vscode.Uri } | undefined)?.cwd?.fsPath ?? ""; @@ -525,14 +764,31 @@ export class DashboardPanel { if (cwdMatches.length === 1) { cwdMatches[0].show(true); return; } } - void vscode.window.showInformationMessage(`Terminal not found for "${nick || root}". Click ⌨ terminal again after the session's next Claude turn.`); - })(); + void vscode.window.showInformationMessage(`⌨ Chat not found for "${nick}". Wait for Claude's next tool call then try again.`); + } catch (err) { + void vscode.window.showErrorMessage(`focusTerminal error: ${err instanceof Error ? err.message : String(err)}`); + } })(); return; } }, null, this._disposables ); this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + vscode.window.onDidCloseTerminal(async (closed) => { + const pid = await closed.processId; + if (!pid) return; + const sessDir = path.join(os.homedir(), ".agentboard", "sessions"); + try { + for (const fname of fs.readdirSync(sessDir)) { + if (!fname.endsWith(".json")) continue; + try { + const raw = fs.readFileSync(path.join(sessDir, fname), "utf8"); + const d = JSON.parse(raw) as { _shell_pid?: number }; + if (d._shell_pid === pid) { fs.unlinkSync(path.join(sessDir, fname)); void this._update(); } + } catch { /* skip */ } + } + } catch { /* dir missing */ } + }, null, this._disposables); } private _buildDataSync(): object { @@ -686,8 +942,8 @@ export class DashboardPanel { ts: ev.ts, done: false, }); - } else if (ev.tool === "Agent" && (ev as {agent?: string}).agent) { - const key = (ev as {agent?: string}).agent ?? ""; + } else if (ev.tool === "AgentDone") { + const key = (ev as {label?: string}).label ?? ""; const existing = agentMap.get(key); if (existing) agentMap.set(key, { ...existing, done: true }); } @@ -704,7 +960,7 @@ export class DashboardPanel { sessionLastSkill: string; sessionLastRole: string; startedAt: string; lastUpdated: string; ageSeconds: number; ctxPct: number | null; stream: string; sessionTime: string; - activity: { file: string; tool: string; count: number; lastTs: string; added?: number; deleted?: number; lineCount?: number }[]; + activity: { file: string; tool: string; count: number; lastTs: string; added?: number; deleted?: number; lineCount?: number; committed?: boolean; isNew?: boolean; isDeleted?: boolean }[]; agents: { label: string; role: string; skill: string; ts: string; done: boolean }[]; hasWorkflow: boolean; workflowAgentCount: number; workflowLabel: string; workflowTranscriptAgents: TranscriptAgent[]; @@ -732,7 +988,7 @@ export class DashboardPanel { return sec < 3600 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; })() : ""; // Per-session activity feed (deduplicated, most recent first) - const sActivity: { file: string; tool: string; count: number; lastTs: string; added?: number; deleted?: number; lineCount?: number }[] = []; + const sActivity: { file: string; tool: string; count: number; lastTs: string; added?: number; deleted?: number; lineCount?: number; committed?: boolean; isNew?: boolean; isDeleted?: boolean }[] = []; const sId = (s._session_id as string) || (ctx.session_id as string) || f.replace(".json", ""); if (sRoot) { const allSEvents = getEventsForRoot(sRoot); // cached — no extra file read @@ -743,8 +999,10 @@ export class DashboardPanel { : allSEvents; const sFileMap = new Map(); for (const ev of [...sEvents].reverse()) { - if (!ev.file && !ev.cmd) continue; - const k = ev.file ?? `$ ${(ev.cmd ?? "").slice(0, 60)}`; + // Skill events may only have ev.skill (not ev.file) — normalize to displayable key + const skillName = ev.tool === 'Skill' ? ((ev as {skill?: string}).skill || ev.file || "") : ""; + if (!ev.file && !ev.cmd && !skillName) continue; + const k = skillName ? `/${skillName}` : (ev.file ?? `$ ${(ev.cmd ?? "").slice(0, 60)}`); const ex = sFileMap.get(k); if (!ex || ev.ts > ex.lastTs) sFileMap.set(k, { tool: ev.tool, count: (ex?.count ?? 0) + 1, lastTs: ev.ts }); else sFileMap.set(k, { ...ex, count: ex.count + 1 }); @@ -792,6 +1050,57 @@ export class DashboardPanel { } } catch { /* file may not exist yet */ } } + // Mark files that have committed changes on this branch vs develop/main merge-base + try { + const COMMITTED_TTL = 30_000; + const cacheKey = sRoot; + const cachedC = this._branchCommittedCache.get(cacheKey); + let committedFiles: Set; + if (cachedC && (Date.now() - cachedC.ts) < COMMITTED_TTL) { + committedFiles = cachedC.files; + } else { + committedFiles = new Set(); + // Find merge-base with develop, then main, then fall back to HEAD~1 + let mergeBase = ""; + for (const base of ["origin/develop", "origin/main", "HEAD~1"]) { + try { + mergeBase = execSync(`git merge-base HEAD ${base}`, { cwd: sRoot, timeout: 3000, encoding: "utf8" }).trim(); + if (mergeBase) break; + } catch { /* try next */ } + } + if (mergeBase) { + const nameOnly = execSync(`git diff --name-only ${mergeBase}..HEAD`, { cwd: sRoot, timeout: 3000, encoding: "utf8" }); + for (const line of nameOnly.split("\n")) { + const f2 = line.trim(); + if (f2) committedFiles.add(f2); + } + } + this._branchCommittedCache.set(cacheKey, { ts: Date.now(), files: committedFiles }); + } + for (const entry of sActivity) { + if (entry.tool === "Edit" || entry.tool === "Write" || entry.tool === "MultiEdit") { + entry.committed = committedFiles.has(entry.file); + } + } + } catch { /* git unavailable */ } + } + // Detect new/deleted files via git status --porcelain + if (sRoot) { + try { + const statusOut = execSync(`git -C "${sRoot}" status --porcelain 2>/dev/null`, { timeout: 3000, encoding: "utf8" }); + const statusMap = new Map(); + for (const line of statusOut.split("\n")) { + if (line.length < 4) continue; + const xy = line.slice(0, 2); + const fpath = line.slice(3).trim().replace(/^"(.*)"$/, "$1"); // git quotes paths with spaces + statusMap.set(fpath, xy); + } + for (const entry of sActivity) { + const xy = statusMap.get(entry.file) ?? statusMap.get(entry.file.replace(/\\/g, "/")) ?? ""; + if (xy === "??" || xy[0] === "A" || xy[1] === "A") entry.isNew = true; + else if (xy[0] === "D" || xy[1] === "D") entry.isDeleted = true; + } + } catch { /* git unavailable */ } } // Skip ghost sessions: no tool events AND session started >15 min ago // Use startedAt age (not lastUpdated) so status-bridge pings don't keep ghosts alive @@ -811,8 +1120,8 @@ export class DashboardPanel { skill: (ev as {skill?: string}).skill ?? "", ts: ev.ts, done: false, }); - } else if (ev.tool === "Agent" && (ev as {agent?: string}).agent) { - const k = (ev as {agent?: string}).agent ?? ""; + } else if (ev.tool === "AgentDone") { + const k = (ev as {label?: string}).label ?? ""; const ex = sAgentMap.get(k); if (ex) sAgentMap.set(k, { ...ex, done: true }); } @@ -954,11 +1263,13 @@ export class DashboardPanel { const evs = getEventsForRoot(sess.root).filter(e => e.session_id === sess.sessionId); const nick = sessionNick(sess.sessionId); for (const ev of evs) { - if (ev.tool === 'Skill' && (ev as {skill?: string}).skill) { - const sk = (ev as {skill?: string}).skill!; - if (!skillUsage.has(sk)) skillUsage.set(sk, []); - if (!skillUsage.get(sk)!.includes(nick)) skillUsage.get(sk)!.push(nick); - sessionLastSkillMap.set(sess.sessionId, sk); // last wins = most recent + if (ev.tool === 'Skill') { + const sk = (ev as {skill?: string}).skill || ev.file || ""; + if (sk) { + if (!skillUsage.has(sk)) skillUsage.set(sk, []); + if (!skillUsage.get(sk)!.includes(nick)) skillUsage.get(sk)!.push(nick); + sessionLastSkillMap.set(sess.sessionId, sk); // last wins = most recent + } } // RoleAdopt: main session read a role file — slug-keyed if (ev.tool === 'RoleAdopt' && (ev as {role?: string}).role) { @@ -1004,9 +1315,57 @@ export class DashboardPanel { }; } + private _handleDelegateFile(): void { + const delegateFile = path.join(os.homedir(), ".agentboard", "delegate.json"); + if (!fs.existsSync(delegateFile)) return; + let raw: string; + try { + raw = fs.readFileSync(delegateFile, "utf8"); + fs.unlinkSync(delegateFile); // delete first — prevent duplicate opens on next tick + } catch { return; } + try { + const d = JSON.parse(raw) as { role?: string; task?: string; context?: string; branch?: string; root?: string; project?: string }; + if (!d.role || !d.task) return; + // Dedup: ignore if same role+task was already handled within 60 seconds + const dedupeKey = `${d.role}|${d.task}`; + if (dedupeKey === this._lastDelegateKey && (Date.now() - this._lastDelegateTs) < 60_000) return; + this._lastDelegateKey = dedupeKey; + this._lastDelegateTs = Date.now(); + const roles = readRoles(d.root ?? this._workspaceRoot); + const roleItem = roles.find(r => r.slug === d.role); + const roleName = roleItem?.name ?? d.role; + const lines: string[] = [ + `Adopt the ${roleName} role for this session.`, + `Read .platform/roles/${d.role}.md for your full protocol, mission, and responsibilities.`, + ]; + if (d.project || d.branch) { + const from = [d.project, d.branch ? `branch: ${d.branch}` : ""].filter(Boolean).join(" — "); + lines.push(`\nHandoff from: ${from}`); + } + if (d.context) lines.push(d.context); + lines.push(`\nYour task: ${d.task}`); + lines.push(`\nAsk me 2–3 focused intake questions if anything needs clarification, then begin.`); + const prompt = lines.join("\n"); + const escaped = prompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`"); + const cwd = d.root && fs.existsSync(d.root) ? d.root : this._workspaceRoot; + const termName = `Claude · ${roleName}`; + const terminal = vscode.window.createTerminal({ name: termName, cwd }); + terminal.show(); + terminal.sendText(`claude "${escaped}"`, true); + this._sessionTerminalMap.set(d.role, termName); + } catch { /* malformed delegate.json — silently ignore */ } + } + + private _deleteSessionFile(sessionId: string): void { + const sessDir = path.join(os.homedir(), ".agentboard", "sessions"); + const f = path.join(sessDir, `${sessionId}.json`); + try { fs.unlinkSync(f); } catch { /* already gone */ } + } + private async _update(): Promise { if (!this._initialized) return; + this._handleDelegateFile(); const data = this._buildDataSync() as Record; // HTTP calls for sessions/worktrees: skip entirely when server is consistently absent (backoff) @@ -1048,8 +1407,9 @@ export class DashboardPanel { body{background:var(--vscode-editor-background);color:var(--vscode-editor-foreground);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;height:100vh;display:flex;flex-direction:column;overflow:hidden} #hdr{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--vscode-panel-border);flex-shrink:0} .logo{color:#4a9eff;font-weight:700;letter-spacing:.08em;font-size:11px}.sep{opacity:.25}.proj{opacity:.65;font-size:12px}.br{opacity:.4;font-size:11px} -.rbtn{margin-left:auto;background:transparent;border:1px solid var(--vscode-panel-border);color:inherit;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px} +.rbtn{margin-left:auto;background:transparent;border:1px solid var(--vscode-panel-border);color:inherit;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px;transition:opacity .1s} .rbtn:hover{background:var(--vscode-list-hoverBackground)} +.rbtn:active{opacity:.5} .tabs{display:flex;border-bottom:1px solid var(--vscode-panel-border);flex-shrink:0;padding:0 14px} .tab{padding:5px 12px;font-size:12px;cursor:pointer;border:none;border-bottom:2px solid transparent;opacity:.45;transition:all .15s;background:none;color:inherit} .tab.on{opacity:1;border-bottom-color:#4a9eff;color:#4a9eff} diff --git a/lib/agentboard/commands/control_plane.sh b/lib/agentboard/commands/control_plane.sh index 4cf3c4b..74ed67e 100644 --- a/lib/agentboard/commands/control_plane.sh +++ b/lib/agentboard/commands/control_plane.sh @@ -51,23 +51,6 @@ for r in rows: " } -cmd_delegate() { - local task="${*}" - [[ -n "$task" ]] || die "Usage: ab delegate " - _cp_is_running || die "Control plane not running. Run: ab start" - local payload; payload="{\"task\":$(printf "%s" "$task" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")" - local stream_slug; stream_slug="$(frontmatter_value "$(ls .platform/work/*.md 2>/dev/null | head -1)" "slug" 2>/dev/null || true)" - [[ -n "$stream_slug" ]] && payload="${payload},\"stream_slug\":\"${stream_slug}\"" - payload="${payload}}" - curl -sf -m 5 -X POST -H "Content-Type: application/json" -d "$payload" "$(_cp_url)/delegate" 2>/dev/null | python3 -c " -import sys, json -d = json.load(sys.stdin) -print(f'Role: {d[\"role\"][\"name\"]} [{d[\"role\"][\"slug\"]}] (model: {d[\"role\"][\"model\"]})') -print(f'Worktree recommended: {d[\"suggest_worktree\"]}') -print('\n--- Delegation prompt ---') -print(d['prompt']) -" -} cmd_worktree() { local sub="${1:-list}"; shift 2>/dev/null || true diff --git a/lib/agentboard/commands/delegate.sh b/lib/agentboard/commands/delegate.sh new file mode 100644 index 0000000..2e82f86 --- /dev/null +++ b/lib/agentboard/commands/delegate.sh @@ -0,0 +1,77 @@ +# delegate.sh — queue a specialist role for VS Code to open in a new terminal +# Usage: agentboard delegate "" +# +# Writes ~/.agentboard/delegate.json, which the VS Code extension detects on its +# next poll tick and opens a new terminal running: +# claude "Adopt the . Context: <...>. Your task: " + +cmd_delegate() { + local role_slug="${1:-}" + local task="${2:-}" + + if [[ -z "$role_slug" || -z "$task" ]]; then + die "Usage: agentboard delegate \"\"" + fi + + # Resolve project root + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + local project_name + project_name="$(basename "$root")" + + # Current git branch + local branch + branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null)" || branch="unknown" + + # Build context: What are we building + Current state from BRIEF.md + local context="" + local brief="$root/.platform/work/BRIEF.md" + if [[ -f "$brief" ]]; then + local what_building current_state + what_building="$(markdown_section_excerpt "$brief" "## What we're building" 2>/dev/null)" || what_building="" + current_state="$(markdown_section_excerpt "$brief" "## Current state" 2>/dev/null)" || current_state="" + [[ -n "$what_building" ]] && context="$what_building" + if [[ -n "$current_state" ]]; then + [[ -n "$context" ]] && context="$context + +Current state: $current_state" || context="$current_state" + fi + fi + + # Fall back to the stream objective if BRIEF is empty + if [[ -z "$context" ]]; then + local active="$root/.platform/work/ACTIVE.md" + if [[ -f "$active" ]]; then + context="$(grep -m1 'objective:' "$active" | sed 's/.*objective: *//' | tr -d '"' 2>/dev/null)" || context="" + fi + fi + + # Write JSON via Python3 so arbitrary text is correctly escaped + local delegate_dir="$HOME/.agentboard" + mkdir -p "$delegate_dir" + + ROLE="$role_slug" \ + TASK="$task" \ + CONTEXT="$context" \ + BRANCH="$branch" \ + ROOT="$root" \ + PROJECT="$project_name" \ + python3 -c " +import json, os, datetime +print(json.dumps({ + 'role': os.environ['ROLE'], + 'task': os.environ['TASK'], + 'context': os.environ['CONTEXT'], + 'branch': os.environ['BRANCH'], + 'root': os.environ['ROOT'], + 'project': os.environ['PROJECT'], + 'ts': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), +})) +" > "$delegate_dir/delegate.json" + + ok "Delegation queued → ${role_slug}: ${task}" + printf ' VS Code will open a new terminal with Claude as %s%s%s.\n\n' "$C_BOLD" "$role_slug" "$C_RESET" + printf ' %s⚠ YOUR TURN IS DONE. Do NOT continue this work, spawn sub-agents,\n' "$C_YELLOW" + printf ' or use the Agent tool for the delegated task. The specialist terminal\n' + printf ' will open automatically in VS Code. Just stop.%s\n' "$C_RESET" +} diff --git a/lib/agentboard/commands/help.sh b/lib/agentboard/commands/help.sh index b7b6c22..e5203c4 100644 --- a/lib/agentboard/commands/help.sh +++ b/lib/agentboard/commands/help.sh @@ -55,6 +55,11 @@ COMMANDS next-action [slug] Print the canonical next action for a stream --session-id resolve stream from session --quiet print only the action text + delegate "" + Open a new VS Code terminal with Claude pre-seeded + as the given role. Passes current branch + BRIEF + context automatically. HARD STOP after running — + do not spawn sub-agents or continue the task. handoff [stream-slug] Print a low-token provider handoff packet. Shows Resume state (from stream file), warns if stale, appends a "for the agent reading this" diff --git a/templates/platform/scripts/hooks/event-logger.sh b/templates/platform/scripts/hooks/event-logger.sh index 5652cab..fae454c 100755 --- a/templates/platform/scripts/hooks/event-logger.sh +++ b/templates/platform/scripts/hooks/event-logger.sh @@ -156,6 +156,17 @@ if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_start" ]]; then exit 0 fi +# ── Agent done (PostToolUse on Agent tool) ──────────────────────────────────── +# Matches the AgentStart label so the dashboard can flip done=true. +if [[ "${AGENTBOARD_HOOK_TYPE:-}" == "agent_done" ]]; then + _label="$(_json_string_field "label")" + _task="${_label:-sub-agent}" + printf '{"ts":"%s","provider":"%s","stream":"%s","tool":"AgentDone","label":"%s","session_id":"%s"}\n' \ + "$ts" "$provider_e" "$stream_e" \ + "$(_jsesc "$_task")" "$(_jsesc "$session_id")" >> "$log_file" 2>/dev/null + exit 0 +fi + # Skip Bash events that are ab meta-calls — those commands produce # their own structured events (Reason, checkpoint, etc.) which are the signal. if [[ "$tool" == "Bash" ]]; then diff --git a/templates/root/.claude/settings.json b/templates/root/.claude/settings.json index 1261b02..022b39e 100644 --- a/templates/root/.claude/settings.json +++ b/templates/root/.claude/settings.json @@ -48,6 +48,16 @@ } ], "PostToolUse": [ + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "bash -c 'ROOT=$(git rev-parse --show-toplevel 2>/dev/null); [ -n \"$ROOT\" ] && [ -f \"$ROOT/.platform/scripts/hooks/event-logger.sh\" ] && { cd \"$ROOT\" && AGENTBOARD_HOOK_TYPE=agent_done bash \".platform/scripts/hooks/event-logger.sh\"; } || exit 0'", + "timeout": 3 + } + ] + }, { "hooks": [ { diff --git a/templates/root/CLAUDE.md.template b/templates/root/CLAUDE.md.template index a594f53..c8928a3 100644 --- a/templates/root/CLAUDE.md.template +++ b/templates/root/CLAUDE.md.template @@ -68,6 +68,28 @@ For `--cumulative-in` / `--cumulative-out` pass Claude Code's **current session This overwrites the stream file's `## Resume state` block with compact "where we are" state and trims the progress log to the last 10 entries. The next agent runs `ab handoff ` and picks up from there — **no re-explaining the feature**. Without this, the next agent has only stale state. +### Delegating to a specialist (multi-agent handoff) + +When the user asks you to hand off work to another role (e.g. "call QA", "delegate to the security engineer", "get the refactor architect to look at this"), run: + +```bash +ab delegate "" +``` + +Example: +```bash +ab delegate qa-engineer "QA the variation carousel refactor on fix/polish-release" +``` + +`ab delegate` writes a handoff packet — your current BRIEF context, branch, and task — to a file that VS Code picks up and opens as a **new terminal** with Claude pre-seeded in that role. + +**After running `ab delegate`, your job is done. Hard stop. Do NOT:** +- Spawn a sub-agent or use the `Agent` tool to do the delegated work +- Continue the task yourself in this session +- Interpret the command output as instructions to do more work + +The specialist opens in their own terminal. You are done. Any further action on the delegated task from your session is a mistake. + ### Reasoning annotation — after significant edits (mandatory) After every Write or Edit that another agent will need to understand, run: