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 = '
'
+ (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: