@hueyexe/opencode-ensemble 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,12 @@
1
+ <p align="center">
2
+ <img src="social-preview.png" alt="OpenCode Ensemble - Parallel agents. One coordinated team." width="100%">
3
+ </p>
4
+
1
5
  # OpenCode Ensemble
2
6
 
3
7
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
8
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
- [![tests](https://img.shields.io/badge/tests-492%20passing-brightgreen.svg)]()
9
+ [![tests](https://img.shields.io/badge/tests-512%20passing-brightgreen.svg)]()
6
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
11
  [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
8
12
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
@@ -15,7 +19,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies.
15
19
 
16
20
  ```json
17
21
  {
18
- "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
22
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.3"]
19
23
  }
20
24
  ```
21
25
 
@@ -136,7 +140,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
136
140
 
137
141
  ```json
138
142
  {
139
- "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
143
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.3"]
140
144
  }
141
145
  ```
142
146
 
@@ -144,7 +148,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
144
148
 
145
149
  ```json
146
150
  {
147
- "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
151
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.3"]
148
152
  }
149
153
  ```
150
154
 
@@ -406,7 +410,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
406
410
  ```bash
407
411
  bun install
408
412
  bun run typecheck
409
- bun test # 492 tests
413
+ bun test # 512 tests
410
414
  bun run build
411
415
  ```
412
416
 
package/dist/index.js CHANGED
@@ -15987,7 +15987,7 @@ function buildLeadSystemPrompt(db, teamId, config2) {
15987
15987
  }
15988
15988
  lines.push(`If user picks "I'll choose per agent", ask for each agent's model individually before each team_spawn call.`, "Pass the chosen model via the model parameter on each team_spawn call.");
15989
15989
  }
15990
- lines.push("", "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.", "This avoids git worktree contention. Once all are spawned, wait for their messages.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which squash-merges and unstages for you.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15990
+ lines.push("", "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.", "This avoids git worktree contention. Once all are spawned, wait for their messages.", "Read-only agents (explore, plan) automatically skip worktree creation.", "For other agents that only need to read, pass worktree: false to avoid unnecessary isolation.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which squash-merges and unstages for you.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15991
15991
  return lines.join(`
15992
15992
  `);
15993
15993
  }
@@ -16271,7 +16271,7 @@ var DASHBOARD_HEAD = `<!DOCTYPE html>
16271
16271
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16272
16272
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
16273
16273
  <script src="https://cdn.tailwindcss.com"></script>
16274
- <script>tailwind.config={theme:{extend:{colors:{base:{950:'#0c0e14',900:'#141822',850:'#1a1f2e',800:'#1e2433',700:'#2a3144',600:'#3a4358'},txt:{100:'#e2e8f0',200:'#c1c9d9',300:'#8892a8',400:'#5e6a82',500:'#4a5568'}},fontFamily:{sans:['Inter','system-ui','sans-serif'],mono:['JetBrains Mono','monospace']}}}}</script>
16274
+ <script>tailwind.config={theme:{extend:{colors:{base:{950:'#0c0e14',900:'#141822',850:'#1a1f2e',800:'#1e2433',700:'#2a3144',600:'#3a4358'},txt:{100:'#e2e8f0',200:'#c1c9d9',300:'#aab4c6',400:'#8a96aa',500:'#7b879b'}},fontFamily:{sans:['Inter','system-ui','sans-serif'],mono:['JetBrains Mono','monospace']}}}}</script>
16275
16275
  <style>
16276
16276
  @media(prefers-reduced-motion:no-preference){
16277
16277
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
@@ -16283,8 +16283,10 @@ var DASHBOARD_HEAD = `<!DOCTYPE html>
16283
16283
  .hl{animation:hl 1.5s ease-out}
16284
16284
  .shimmer{background:linear-gradient(90deg,#22c55e 0%,#4ade80 50%,#22c55e 100%)!important;background-size:200% 100%;animation:shimmer 1.5s ease-in-out}
16285
16285
  }
16286
+ @media(prefers-reduced-motion:reduce){*,::before,::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
16286
16287
  .scroll::-webkit-scrollbar{width:5px}.scroll::-webkit-scrollbar-track{background:transparent}.scroll::-webkit-scrollbar-thumb{background:#2a3144;border-radius:3px}
16287
16288
  details summary::-webkit-details-marker{display:none}details summary{list-style:none}
16289
+ :focus-visible{outline:2px solid rgba(96,165,250,.85);outline-offset:2px}
16288
16290
  select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%235e6a82' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:22px}
16289
16291
  .xp{max-height:0;overflow:hidden;transition:max-height .3s ease-out}.xp-open{max-height:3000px;transition:max-height .5s ease-in}
16290
16292
  .card-sel{outline:2px solid rgba(59,130,246,.4);outline-offset:1px}
@@ -16303,13 +16305,13 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
16303
16305
  </style>
16304
16306
  </head>
16305
16307
  <body class="bg-base-950 text-txt-100 min-h-screen antialiased font-sans">
16306
- <header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-4 z-50">
16307
- <div class="flex items-center gap-3">
16308
+ <header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-3 sm:px-4 z-50">
16309
+ <div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
16308
16310
  <span class="font-mono font-semibold text-[13px] tracking-[.08em] text-txt-200">ensemble</span>
16309
16311
  <span class="text-base-700">|</span>
16310
- <select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors"></select>
16312
+ <select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors max-w-[180px] sm:max-w-[320px] min-w-0"></select>
16311
16313
  </div>
16312
- <div class="flex items-center gap-4">
16314
+ <div class="flex items-center gap-2 sm:gap-4 shrink-0">
16313
16315
  <div id="hring" class="w-6 h-6 rounded-full" title="Team health"></div>
16314
16316
  <div class="flex items-center gap-2">
16315
16317
  <span id="clk" class="text-[11px] text-txt-400 font-mono"></span>
@@ -16319,25 +16321,28 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
16319
16321
  </div>
16320
16322
  </div>
16321
16323
  </header>
16322
- <div id="sum" class="fixed top-11 inset-x-0 h-8 bg-base-900/80 backdrop-blur border-b border-base-800/50 flex items-center px-4 gap-4 text-[11px] text-txt-300 z-40"></div>
16324
+ <div id="sum" class="fixed top-11 inset-x-0 h-8 bg-base-900/80 backdrop-blur border-b border-base-800/50 flex items-center px-3 sm:px-4 gap-3 sm:gap-4 text-[11px] text-txt-300 z-40 overflow-x-auto scroll whitespace-nowrap"></div>
16323
16325
  <main class="pt-[76px] px-4 pb-16">
16324
16326
  <div id="empty" class="hidden flex-col items-center justify-center h-[70vh] gap-3">
16325
16327
  <div class="w-12 h-12 rounded-full border-2 border-base-700 flex items-center justify-center mb-2"><svg class="w-5 h-5 text-txt-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 4.5v15m7.5-7.5h-15"/></svg></div>
16326
16328
  <div class="text-txt-400 text-sm">Waiting for a team</div>
16327
16329
  <div class="text-txt-500 text-[11px]">Run <code class="px-1.5 py-0.5 bg-base-900 rounded text-txt-300 font-mono text-[11px]">team_create</code> in OpenCode to get started</div>
16328
16330
  </div>
16329
- <div id="content" class="hidden">
16330
- <div id="agents" class="grid gap-2 mb-4" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div>
16331
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
16332
- <div id="tasks" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div>
16333
- <div id="activity" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div>
16331
+ <div id="content" class="hidden max-w-[1600px] mx-auto">
16332
+ <section id="attention" aria-label="Team attention" class="mb-3"></section>
16333
+ <div class="grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
16334
+ <section aria-label="Agent roster"><div id="agents" class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div></section>
16335
+ <div class="grid grid-cols-1 gap-4">
16336
+ <section aria-label="Task board"><div id="tasks" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div></section>
16337
+ <section aria-label="Activity feed"><div id="activity" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div></section>
16338
+ </div>
16334
16339
  </div>
16335
16340
  </div>
16336
16341
  </main>
16337
- <div id="tl" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
16338
- <div id="sco" onclick="this.classList.remove('show')">
16342
+ <div id="tl" aria-label="Event timeline" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
16343
+ <div id="sco" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick="closeShortcuts()">
16339
16344
  <div class="bg-base-900 border border-base-800 rounded-lg p-6 max-w-sm" onclick="event.stopPropagation()">
16340
- <div class="text-txt-200 font-semibold text-sm mb-4">Keyboard Shortcuts</div>
16345
+ <div id="shortcuts-title" class="text-txt-200 font-semibold text-sm mb-4">Keyboard Shortcuts</div>
16341
16346
  <div class="grid grid-cols-2 gap-y-2 gap-x-6 text-[12px]">
16342
16347
  <div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">j</kbd> <span class="text-txt-400">Next agent</span></div>
16343
16348
  <div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">k</kbd> <span class="text-txt-400">Prev agent</span></div>
@@ -16349,7 +16354,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
16349
16354
  </div>
16350
16355
  </div>
16351
16356
  <div id="drawer-bg" onclick="closeDrawer()"></div>
16352
- <div id="drawer" class="scroll p-4"></div>`;
16357
+ <div id="drawer" class="scroll p-4" tabindex="-1" inert role="dialog" aria-modal="true" aria-labelledby="drawer-title" aria-hidden="true"><h2 id="drawer-title" class="sr-only">Agent detail</h2></div>`;
16353
16358
 
16354
16359
  // src/dashboard-js-part1.ts
16355
16360
  var DASHBOARD_JS_PART1 = `
@@ -16423,6 +16428,40 @@ function deriveHealth(t){
16423
16428
  return{w:mm.filter(m=>m.status==='busy').length,i:mm.filter(m=>m.status==='ready').length,e:mm.filter(m=>m.status==='error').length,d:mm.filter(m=>m.status==='shutdown'||m.status==='shutdown_requested').length,total:mm.length};
16424
16429
  }
16425
16430
 
16431
+ function lastMessageFor(name,msgs){return msgs.filter(m=>m.fromName===name||m.toName===name).sort((a,b)=>b.timeCreated-a.timeCreated)[0]||null}
16432
+ function activeTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='in_progress')||tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
16433
+ function blockedTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
16434
+
16435
+ function rankAgent(a,b,t){
16436
+ const tasks=t?.tasks||[],msgs=t?.messages||[];
16437
+ const score=m=>{
16438
+ const lm=lastMessageFor(m.name,msgs),bt=blockedTaskFor(m.name,tasks);
16439
+ let s=0;
16440
+ if(m.status==='error')s-=1000;
16441
+ if(bt)s-=800;
16442
+ if(m.status==='shutdown_requested')s-=650;
16443
+ if(m.status==='busy')s-=500;
16444
+ if(m.status==='ready'&&activeTaskFor(m.name,tasks))s-=350;
16445
+ if(lm)s-=Math.max(0,200-Math.floor((Date.now()-lm.timeCreated)/60000));
16446
+ if(m.status==='shutdown')s+=700;
16447
+ return s;
16448
+ };
16449
+ const d=score(a)-score(b);return d||String(a.name).localeCompare(String(b.name));
16450
+ }
16451
+
16452
+ function deriveAttention(t){
16453
+ const tasks=t.tasks||[],msgs=t.messages||[];
16454
+ const blocked=tasks.filter(x=>x.status==='blocked'),running=tasks.filter(x=>x.status==='in_progress');
16455
+ const errored=(t.members||[]).filter(m=>m.status==='error');
16456
+ const stopping=(t.members||[]).filter(m=>m.status==='shutdown_requested');
16457
+ const latest=msgs[0]||null;
16458
+ const items=[];
16459
+ errored.forEach(m=>items.push({kind:'Agent error',label:m.name,detail:m.executionStatus||m.status,color:'red'}));
16460
+ blocked.forEach(x=>items.push({kind:'Blocked task',label:x.assignee||'unassigned',detail:x.content,color:'amber'}));
16461
+ stopping.forEach(m=>items.push({kind:'Stopping',label:m.name,detail:'shutdown requested',color:'amber'}));
16462
+ return{items,running,latest,blocked,errored};
16463
+ }
16464
+
16426
16465
  function deriveSparkline(name,msgs){
16427
16466
  const mine=msgs.filter(m=>m.fromName===name).map(m=>m.timeCreated).sort();
16428
16467
  if(mine.length<2)return '';
@@ -16498,17 +16537,32 @@ function rSum(t){
16498
16537
  );
16499
16538
  }
16500
16539
 
16540
+ function rAttention(t){
16541
+ const el=document.getElementById('attention'),a=deriveAttention(t),h=deriveHealth(t),tk=t.tasks||[];
16542
+ const tone=a.items.length?'border-amber-500/30 bg-amber-500/[0.05]':'border-emerald-500/20 bg-emerald-500/[0.035]';
16543
+ const lead=a.items.length?'Needs attention':'No blockers detected';
16544
+ const chips=[chip(h.w+' working','blue'),chip(a.running.length+' active tasks','green'),chip(a.blocked.length+' blocked',a.blocked.length?'amber':'muted'),chip(tk.filter(x=>x.status==='pending').length+' pending','muted')];
16545
+ let html='<div class="rounded-lg border '+tone+' px-3 py-2 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">';
16546
+ html+='<div class="min-w-[180px]"><div class="text-[10px] uppercase tracking-[.16em] text-txt-500">Team attention</div><div class="text-[14px] font-semibold text-txt-100">'+lead+'</div></div>';
16547
+ html+='<div class="flex flex-wrap gap-1.5">'+chips.join('')+'</div>';
16548
+ if(a.items.length){html+='<div class="flex-1 grid gap-1 md:grid-cols-2">'+a.items.slice(0,4).map(x=>'<div class="text-[12px] text-txt-200 truncate">'+chip(E(x.kind),x.color)+' <span class="font-mono">'+E(x.label)+'</span> <span class="text-txt-400">'+E(x.detail)+'</span></div>').join('')+'</div>'}
16549
+ else if(a.latest){html+='<div class="text-[12px] text-txt-400 truncate md:ml-auto">Latest: <span class="text-txt-200">'+E(a.latest.fromName)+'</span> '+relT(a.latest.timeCreated)+'</div>'}
16550
+ html+='</div>';
16551
+ patch(el,html);
16552
+ }
16553
+
16501
16554
  function rAgents(t){
16502
16555
  const el=document.getElementById('agents'),mm=t.members||[];
16503
16556
  if(!mm.length){patch(el,'<div class="col-span-full text-center py-12"><div class="text-txt-400 text-sm mb-1">No agents yet</div><div class="text-txt-500 text-[11px]">Spawn teammates with <code class="px-1 py-0.5 bg-base-900 rounded font-mono text-[10px]">team_spawn</code></div></div>');return}
16504
- const n=Date.now(),msgs=t.messages||[],sorted=[...mm].sort((a,b)=>{const o={busy:0,ready:1,shutdown_requested:2,error:3,shutdown:4};return(o[a.status]??5)-(o[b.status]??5)});
16557
+ const n=Date.now(),msgs=t.messages||[],sorted=[...mm].sort((a,b)=>rankAgent(a,b,t));
16505
16558
  const html=sorted.map((m,idx)=>{
16506
- const s=si(m.status),task=(t.tasks||[]).find(x=>x.assignee===m.name&&x.status==='in_progress'),msg=msgs.find(x=>x.fromName===m.name);
16559
+ const s=si(m.status),task=activeTaskFor(m.name,t.tasks||[]),msg=lastMessageFor(m.name,msgs),blocked=blockedTaskFor(m.name,t.tasks||[]);
16507
16560
  const d=D(n-m.timeUpdated),mi=msg?relT(msg.timeCreated):'\\u2014';
16508
16561
  const tt=task?.content,tr=tt&&tt.length>90?tt.slice(0,90)+'\\u2026':tt;
16562
+ const mp=msg?(msg.content.length>90?msg.content.slice(0,90)+'\\u2026':msg.content):'';
16509
16563
  const spark=deriveSparkline(m.name,msgs);
16510
16564
  const isSel=selCard===idx;
16511
- return '<div class="rounded-lg border '+s.c+' p-3 transition-all duration-300 cursor-pointer hover:border-base-600'+(s.dim?' opacity-50 hover:opacity-70':'')+(isSel?' card-sel':'')+'" data-card="'+E(m.name)+'" onclick="openDrawer(\\''+E(m.name)+'\\')">'+
16565
+ return '<button type="button" class="text-left rounded-lg border '+s.c+' p-3 transition-all duration-300 cursor-pointer hover:border-base-600 focus-visible:border-blue-400'+(isSel?' card-sel':'')+'" data-card="'+E(m.name)+'" onclick="openDrawer(this.dataset.card)" onkeydown="if(event.key===\\'Enter\\'||event.key===\\' \\'){event.preventDefault();openDrawer(this.dataset.card)}">'+
16512
16566
  '<div class="flex items-center gap-2">'+
16513
16567
  '<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
16514
16568
  '<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
@@ -16517,11 +16571,12 @@ function rAgents(t){
16517
16571
  '<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
16518
16572
  (m.model?chip(E(m.model),'muted'):'')+
16519
16573
  '</div>'+
16520
- (tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+E(tr)+'</div>':'')+
16574
+ (tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+(blocked?'<span class="text-amber-400">Blocked: </span>':'Current task: ')+E(tr)+'</div>':'<div class="mt-2 text-[13px] text-txt-500 leading-snug">No active task</div>')+
16575
+ (mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
16521
16576
  '<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
16522
- chip(d,'muted')+chip('msg '+mi,'muted')+
16577
+ chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
16523
16578
  (m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
16524
- '</div></div>';
16579
+ '</div></button>';
16525
16580
  }).join('');
16526
16581
  patch(el,html);
16527
16582
  }
@@ -16533,8 +16588,8 @@ function openDrawer(name){
16533
16588
  var h='';
16534
16589
  // Header
16535
16590
  h+='<div class="flex items-center justify-between mb-4">';
16536
- h+='<div class="flex items-center gap-2"><span class="w-[10px] h-[10px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+'"></span><span class="font-mono font-semibold text-[16px]">'+E(m.name)+'</span><span class="text-[11px] px-2 py-[2px] rounded '+s.t+' bg-base-800/80">'+s.l+'</span></div>';
16537
- h+='<button onclick="closeDrawer()" class="text-txt-500 hover:text-txt-200 transition-colors p-1"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button>';
16591
+ h+='<div class="flex items-center gap-2"><span class="w-[10px] h-[10px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+'"></span><h2 id="drawer-title" class="font-mono font-semibold text-[16px]">'+E(m.name)+'</h2><span class="text-[11px] px-2 py-[2px] rounded '+s.t+' bg-base-800/80">'+s.l+'</span></div>';
16592
+ h+='<button id="drawer-close" aria-label="Close agent detail" onclick="closeDrawer()" class="text-txt-500 hover:text-txt-200 transition-colors p-1"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button>';
16538
16593
  h+='</div>';
16539
16594
  // Meta chips
16540
16595
  var meta=[];
@@ -16581,13 +16636,22 @@ function openDrawer(name){
16581
16636
  h+='</div></div>';
16582
16637
  });
16583
16638
  }
16584
- document.getElementById('drawer').innerHTML=h;
16585
- document.getElementById('drawer').classList.add('open');
16639
+ var drawer=document.getElementById('drawer');
16640
+ drawer.innerHTML=h;
16641
+ drawer.classList.add('open');
16642
+ drawer.setAttribute('aria-hidden','false');
16643
+ drawer.inert=false;
16644
+ setBackgroundInert(true);
16645
+ drawer.focus();
16586
16646
  document.getElementById('drawer-bg').classList.add('open');
16587
16647
  }
16588
16648
 
16589
16649
  function closeDrawer(){
16590
- document.getElementById('drawer').classList.remove('open');
16650
+ var drawer=document.getElementById('drawer');
16651
+ drawer.classList.remove('open');
16652
+ drawer.setAttribute('aria-hidden','true');
16653
+ drawer.inert=true;
16654
+ if(!modalOpen())setBackgroundInert(false);
16591
16655
  document.getElementById('drawer-bg').classList.remove('open');
16592
16656
  }
16593
16657
 
@@ -16606,7 +16670,8 @@ function rTasks(t){
16606
16670
  const icl=x.status==='completed'?'text-emerald-500':x.status==='in_progress'?'text-blue-400':'text-txt-500';
16607
16671
  const p=PR[x.priority]||PR.low,cn=x.content.length>55?x.content.slice(0,55)+'\\u2026':x.content;
16608
16672
  let dep='';
16609
- if(x.dependsOn){const parent=taskMap[x.dependsOn];dep='<div class="text-[10px] text-txt-500 mt-0.5">\\u2514\\u2192 '+(parent?E(parent.content.slice(0,30)):E(x.dependsOn))+'</div>'}
16673
+ const depIds=Array.isArray(x.dependsOn)?x.dependsOn:(x.dependsOn?[String(x.dependsOn)]:[]);
16674
+ if(depIds.length){const labels=depIds.map(id=>{const parent=taskMap[id];return parent?parent.content.slice(0,42):id}).join(', ');dep='<div class="text-[10px] text-txt-500 mt-0.5">blocked by: '+E(labels)+'</div>'}
16610
16675
  return '<div class="flex items-start gap-2 py-1.5 px-1 rounded hover:bg-base-800/40 transition-colors">'+
16611
16676
  '<span class="'+icl+' text-[11px] mt-[2px] shrink-0">'+ic+'</span>'+
16612
16677
  '<div class="flex-1 min-w-0"><div class="text-[13px] text-txt-200 truncate">'+E(cn)+'</div>'+
@@ -16633,7 +16698,7 @@ function rActivity(t){
16633
16698
  if(!msgs.length){patch(el,'<div class="text-txt-500 text-sm py-6 text-center">Waiting for agent messages...</div>');return}
16634
16699
  const sp=saveSc(el);
16635
16700
  const newCount=msgs.length,hasNew=newCount>prevMC&&prevMC>0;
16636
- let html='<div class="text-[11px] text-txt-300 font-semibold uppercase tracking-wider mb-3">Activity '+chip(msgs.length+' msgs','muted')+'</div>';
16701
+ let html='<div class="flex items-center gap-2 mb-3"><span class="text-[11px] text-txt-300 font-semibold uppercase tracking-wider">Activity feed</span>'+chip(msgs.length+' msgs','muted')+'</div>';
16637
16702
  html+='<div class="max-h-[50vh] overflow-y-auto scroll space-y-2">';
16638
16703
  msgs.slice(0,30).forEach(function(m,mi){
16639
16704
  const isNew=hasNew&&mi<(newCount-prevMC);
@@ -16646,7 +16711,7 @@ function rActivity(t){
16646
16711
  const bubbleBg=isPeer?'bg-violet-500/[0.06] border-violet-500/15':isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
16647
16712
  const initial=m.fromName.charAt(0).toUpperCase();
16648
16713
  const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isPeer?'bg-violet-500/20 text-violet-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
16649
- html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" data-msg="'+E(m.id)+'" onclick="toggleMsg(\\''+E(m.id)+'\\')">';
16714
+ html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" role="button" tabindex="0" aria-expanded="'+(isExp?'true':'false')+'" data-msg="'+E(m.id)+'" onclick="toggleMsg(this.dataset.msg)" onkeydown="if(event.key===\\'Enter\\'||event.key===\\' \\'){event.preventDefault();toggleMsg(this.dataset.msg)}">';
16650
16715
  html+='<div class="flex items-start gap-2">';
16651
16716
  html+='<span class="w-5 h-5 rounded-full '+avatarColor+' flex items-center justify-center text-[9px] font-semibold shrink-0 mt-0.5">'+E(initial)+'</span>';
16652
16717
  html+='<div class="flex-1 min-w-0 rounded-xl border '+bubbleBg+' px-3 py-2">';
@@ -16693,16 +16758,49 @@ function render(){
16693
16758
  const empty=document.getElementById('empty'),content=document.getElementById('content');
16694
16759
  if(!t){empty.classList.remove('hidden');empty.classList.add('flex');content.classList.add('hidden');document.getElementById('tl').classList.add('hidden');return}
16695
16760
  empty.classList.add('hidden');empty.classList.remove('flex');content.classList.remove('hidden');
16696
- rHealth(t);rSum(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
16761
+ rHealth(t);rSum(t);rAttention(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
16697
16762
  }
16698
16763
 
16699
16764
  function conn(ok){
16700
16765
  document.getElementById('cd').className='w-[7px] h-[7px] rounded-full '+(ok?'bg-emerald-500 pulse':'bg-red-500');
16701
- document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'disconnected';
16766
+ document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'reconnecting to dashboard state';
16702
16767
  }
16703
16768
 
16704
16769
  async function poll(){try{S=await(await fetch('/api/state')).json();fails=0;pollT=Date.now();conn(true);render()}catch{if(++fails>=3)conn(false)}}
16705
16770
 
16771
+ function setBackgroundInert(locked){
16772
+ document.querySelectorAll('header,main,#sum,#tl').forEach(function(el){
16773
+ el.inert=locked;
16774
+ if(locked)el.setAttribute('aria-hidden','true');
16775
+ else el.removeAttribute('aria-hidden');
16776
+ });
16777
+ }
16778
+
16779
+ function modalOpen(){return document.getElementById('sco').classList.contains('show')||document.getElementById('drawer').classList.contains('open')}
16780
+
16781
+ function trapFocus(root,e){
16782
+ const nodes=[...root.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(function(el){return !el.disabled&&!el.inert&&el.offsetParent!==null});
16783
+ if(!nodes.length){e.preventDefault();root.focus();return}
16784
+ const first=nodes[0],last=nodes[nodes.length-1];
16785
+ if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();return}
16786
+ if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();return}
16787
+ }
16788
+
16789
+ function openShortcuts(){
16790
+ const el=document.getElementById('sco');
16791
+ setBackgroundInert(true);
16792
+ el.classList.add('show');
16793
+ el.setAttribute('aria-hidden','false');
16794
+ document.getElementById('sco').focus();
16795
+ }
16796
+
16797
+ function closeShortcuts(){
16798
+ const el=document.getElementById('sco');
16799
+ el.classList.remove('show');
16800
+ el.setAttribute('aria-hidden','true');
16801
+ if(!modalOpen())setBackgroundInert(false);
16802
+ }
16803
+
16706
16804
  // Clock update every second
16707
16805
  setInterval(function(){var t=cur();if(t)rClock(t);if(fails<3)conn(true)},1000);
16708
16806
 
@@ -16711,11 +16809,18 @@ setInterval(poll,2500);
16711
16809
 
16712
16810
  // Keyboard shortcuts
16713
16811
  document.addEventListener('keydown',function(e){
16812
+ const shortcutsOpen=document.getElementById('sco').classList.contains('show');
16813
+ if(shortcutsOpen&&e.key==='Tab'){trapFocus(document.getElementById('sco'),e);return}
16814
+ if(shortcutsOpen&&e.key==='Escape'){e.preventDefault();closeShortcuts();return}
16815
+ const drawerOpen=document.getElementById('drawer').classList.contains('open');
16816
+ if(drawerOpen&&e.key==='Tab'){trapFocus(document.getElementById('drawer'),e);return}
16817
+ if(drawerOpen&&e.key==='Escape'){e.preventDefault();closeDrawer();return}
16818
+ if(drawerOpen&&e.key==='?'){e.preventDefault();return}
16714
16819
  if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT'||e.target.tagName==='TEXTAREA')return;
16715
16820
  var t=cur();if(!t)return;
16716
- var mm=t.members||[];
16717
- if(e.key==='?'){e.preventDefault();document.getElementById('sco').classList.toggle('show');return}
16718
- if(e.key==='Escape'){closeDrawer();expMsgs.clear();selCard=-1;document.getElementById('sco').classList.remove('show');render();return}
16821
+ var mm=[...(t.members||[])].sort((a,b)=>rankAgent(a,b,t));
16822
+ if(e.key==='?'){e.preventDefault();shortcutsOpen?closeShortcuts():openShortcuts();return}
16823
+ if(e.key==='Escape'){closeDrawer();expMsgs.clear();selCard=-1;closeShortcuts();render();return}
16719
16824
  if(e.key==='j'&&mm.length){e.preventDefault();selCard=Math.min(selCard+1,mm.length-1);render();return}
16720
16825
  if(e.key==='k'&&mm.length){e.preventDefault();selCard=Math.max(selCard-1,0);render();return}
16721
16826
  if(e.key==='Enter'&&selCard>=0&&selCard<mm.length){e.preventDefault();openDrawer(mm[selCard].name);return}
@@ -16745,6 +16850,20 @@ function jsonResponse(data, status = 200) {
16745
16850
  headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
16746
16851
  });
16747
16852
  }
16853
+ function parseDependsOn(value) {
16854
+ if (!value)
16855
+ return [];
16856
+ try {
16857
+ const parsed = JSON.parse(value);
16858
+ if (Array.isArray(parsed))
16859
+ return parsed.filter((item) => typeof item === "string");
16860
+ if (typeof parsed === "string")
16861
+ return [parsed];
16862
+ } catch {
16863
+ return [value];
16864
+ }
16865
+ return [];
16866
+ }
16748
16867
  function buildState(db) {
16749
16868
  const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all();
16750
16869
  const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?");
@@ -16776,7 +16895,7 @@ function buildState(db) {
16776
16895
  status: tk.status,
16777
16896
  priority: tk.priority,
16778
16897
  assignee: tk.assignee,
16779
- dependsOn: tk.depends_on,
16898
+ dependsOn: parseDependsOn(tk.depends_on),
16780
16899
  timeCreated: tk.time_created,
16781
16900
  timeUpdated: tk.time_updated
16782
16901
  })),
@@ -16949,7 +17068,8 @@ async function executeTeamSpawn(deps, args, sessionId) {
16949
17068
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
16950
17069
  if (existing)
16951
17070
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
16952
- const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
17071
+ const isReadOnly = args.agent === "plan" || args.agent === "explore";
17072
+ const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory);
16953
17073
  const usePlanApproval = args.plan_approval === true;
16954
17074
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
16955
17075
  let worktreeDir = null;
@@ -16989,7 +17109,6 @@ async function executeTeamSpawn(deps, args, sessionId) {
16989
17109
  log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
16990
17110
  }
16991
17111
  }
16992
- const isReadOnly = args.agent === "plan" || args.agent === "explore";
16993
17112
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
16994
17113
  const permission = [];
16995
17114
  if (worktreeDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,8 @@
23
23
  "dist",
24
24
  "src",
25
25
  "LICENSE",
26
- "README.md"
26
+ "README.md",
27
+ "social-preview.png"
27
28
  ],
28
29
  "scripts": {
29
30
  "lint": "bunx biome lint src/",
Binary file
@@ -10,7 +10,7 @@ export const DASHBOARD_HEAD = `<!DOCTYPE html>
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
12
12
  <script src="https://cdn.tailwindcss.com"><\/script>
13
- <script>tailwind.config={theme:{extend:{colors:{base:{950:'#0c0e14',900:'#141822',850:'#1a1f2e',800:'#1e2433',700:'#2a3144',600:'#3a4358'},txt:{100:'#e2e8f0',200:'#c1c9d9',300:'#8892a8',400:'#5e6a82',500:'#4a5568'}},fontFamily:{sans:['Inter','system-ui','sans-serif'],mono:['JetBrains Mono','monospace']}}}}<\/script>
13
+ <script>tailwind.config={theme:{extend:{colors:{base:{950:'#0c0e14',900:'#141822',850:'#1a1f2e',800:'#1e2433',700:'#2a3144',600:'#3a4358'},txt:{100:'#e2e8f0',200:'#c1c9d9',300:'#aab4c6',400:'#8a96aa',500:'#7b879b'}},fontFamily:{sans:['Inter','system-ui','sans-serif'],mono:['JetBrains Mono','monospace']}}}}<\/script>
14
14
  <style>
15
15
  @media(prefers-reduced-motion:no-preference){
16
16
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
@@ -22,8 +22,10 @@ export const DASHBOARD_HEAD = `<!DOCTYPE html>
22
22
  .hl{animation:hl 1.5s ease-out}
23
23
  .shimmer{background:linear-gradient(90deg,#22c55e 0%,#4ade80 50%,#22c55e 100%)!important;background-size:200% 100%;animation:shimmer 1.5s ease-in-out}
24
24
  }
25
+ @media(prefers-reduced-motion:reduce){*,::before,::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
25
26
  .scroll::-webkit-scrollbar{width:5px}.scroll::-webkit-scrollbar-track{background:transparent}.scroll::-webkit-scrollbar-thumb{background:#2a3144;border-radius:3px}
26
27
  details summary::-webkit-details-marker{display:none}details summary{list-style:none}
28
+ :focus-visible{outline:2px solid rgba(96,165,250,.85);outline-offset:2px}
27
29
  select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%235e6a82' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:22px}
28
30
  .xp{max-height:0;overflow:hidden;transition:max-height .3s ease-out}.xp-open{max-height:3000px;transition:max-height .5s ease-in}
29
31
  .card-sel{outline:2px solid rgba(59,130,246,.4);outline-offset:1px}
@@ -42,13 +44,13 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
42
44
  </style>
43
45
  </head>
44
46
  <body class="bg-base-950 text-txt-100 min-h-screen antialiased font-sans">
45
- <header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-4 z-50">
46
- <div class="flex items-center gap-3">
47
+ <header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-3 sm:px-4 z-50">
48
+ <div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
47
49
  <span class="font-mono font-semibold text-[13px] tracking-[.08em] text-txt-200">ensemble</span>
48
50
  <span class="text-base-700">|</span>
49
- <select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors"></select>
51
+ <select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors max-w-[180px] sm:max-w-[320px] min-w-0"></select>
50
52
  </div>
51
- <div class="flex items-center gap-4">
53
+ <div class="flex items-center gap-2 sm:gap-4 shrink-0">
52
54
  <div id="hring" class="w-6 h-6 rounded-full" title="Team health"></div>
53
55
  <div class="flex items-center gap-2">
54
56
  <span id="clk" class="text-[11px] text-txt-400 font-mono"></span>
@@ -58,25 +60,28 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
58
60
  </div>
59
61
  </div>
60
62
  </header>
61
- <div id="sum" class="fixed top-11 inset-x-0 h-8 bg-base-900/80 backdrop-blur border-b border-base-800/50 flex items-center px-4 gap-4 text-[11px] text-txt-300 z-40"></div>
63
+ <div id="sum" class="fixed top-11 inset-x-0 h-8 bg-base-900/80 backdrop-blur border-b border-base-800/50 flex items-center px-3 sm:px-4 gap-3 sm:gap-4 text-[11px] text-txt-300 z-40 overflow-x-auto scroll whitespace-nowrap"></div>
62
64
  <main class="pt-[76px] px-4 pb-16">
63
65
  <div id="empty" class="hidden flex-col items-center justify-center h-[70vh] gap-3">
64
66
  <div class="w-12 h-12 rounded-full border-2 border-base-700 flex items-center justify-center mb-2"><svg class="w-5 h-5 text-txt-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 4.5v15m7.5-7.5h-15"/></svg></div>
65
67
  <div class="text-txt-400 text-sm">Waiting for a team</div>
66
68
  <div class="text-txt-500 text-[11px]">Run <code class="px-1.5 py-0.5 bg-base-900 rounded text-txt-300 font-mono text-[11px]">team_create</code> in OpenCode to get started</div>
67
69
  </div>
68
- <div id="content" class="hidden">
69
- <div id="agents" class="grid gap-2 mb-4" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div>
70
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
71
- <div id="tasks" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div>
72
- <div id="activity" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div>
70
+ <div id="content" class="hidden max-w-[1600px] mx-auto">
71
+ <section id="attention" aria-label="Team attention" class="mb-3"></section>
72
+ <div class="grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
73
+ <section aria-label="Agent roster"><div id="agents" class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div></section>
74
+ <div class="grid grid-cols-1 gap-4">
75
+ <section aria-label="Task board"><div id="tasks" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div></section>
76
+ <section aria-label="Activity feed"><div id="activity" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div></section>
77
+ </div>
73
78
  </div>
74
79
  </div>
75
80
  </main>
76
- <div id="tl" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
77
- <div id="sco" onclick="this.classList.remove('show')">
81
+ <div id="tl" aria-label="Event timeline" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
82
+ <div id="sco" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick="closeShortcuts()">
78
83
  <div class="bg-base-900 border border-base-800 rounded-lg p-6 max-w-sm" onclick="event.stopPropagation()">
79
- <div class="text-txt-200 font-semibold text-sm mb-4">Keyboard Shortcuts</div>
84
+ <div id="shortcuts-title" class="text-txt-200 font-semibold text-sm mb-4">Keyboard Shortcuts</div>
80
85
  <div class="grid grid-cols-2 gap-y-2 gap-x-6 text-[12px]">
81
86
  <div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">j</kbd> <span class="text-txt-400">Next agent</span></div>
82
87
  <div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">k</kbd> <span class="text-txt-400">Prev agent</span></div>
@@ -88,4 +93,4 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
88
93
  </div>
89
94
  </div>
90
95
  <div id="drawer-bg" onclick="closeDrawer()"></div>
91
- <div id="drawer" class="scroll p-4"></div>`;
96
+ <div id="drawer" class="scroll p-4" tabindex="-1" inert role="dialog" aria-modal="true" aria-labelledby="drawer-title" aria-hidden="true"><h2 id="drawer-title" class="sr-only">Agent detail</h2></div>`;
@@ -70,6 +70,40 @@ function deriveHealth(t){
70
70
  return{w:mm.filter(m=>m.status==='busy').length,i:mm.filter(m=>m.status==='ready').length,e:mm.filter(m=>m.status==='error').length,d:mm.filter(m=>m.status==='shutdown'||m.status==='shutdown_requested').length,total:mm.length};
71
71
  }
72
72
 
73
+ function lastMessageFor(name,msgs){return msgs.filter(m=>m.fromName===name||m.toName===name).sort((a,b)=>b.timeCreated-a.timeCreated)[0]||null}
74
+ function activeTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='in_progress')||tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
75
+ function blockedTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
76
+
77
+ function rankAgent(a,b,t){
78
+ const tasks=t?.tasks||[],msgs=t?.messages||[];
79
+ const score=m=>{
80
+ const lm=lastMessageFor(m.name,msgs),bt=blockedTaskFor(m.name,tasks);
81
+ let s=0;
82
+ if(m.status==='error')s-=1000;
83
+ if(bt)s-=800;
84
+ if(m.status==='shutdown_requested')s-=650;
85
+ if(m.status==='busy')s-=500;
86
+ if(m.status==='ready'&&activeTaskFor(m.name,tasks))s-=350;
87
+ if(lm)s-=Math.max(0,200-Math.floor((Date.now()-lm.timeCreated)/60000));
88
+ if(m.status==='shutdown')s+=700;
89
+ return s;
90
+ };
91
+ const d=score(a)-score(b);return d||String(a.name).localeCompare(String(b.name));
92
+ }
93
+
94
+ function deriveAttention(t){
95
+ const tasks=t.tasks||[],msgs=t.messages||[];
96
+ const blocked=tasks.filter(x=>x.status==='blocked'),running=tasks.filter(x=>x.status==='in_progress');
97
+ const errored=(t.members||[]).filter(m=>m.status==='error');
98
+ const stopping=(t.members||[]).filter(m=>m.status==='shutdown_requested');
99
+ const latest=msgs[0]||null;
100
+ const items=[];
101
+ errored.forEach(m=>items.push({kind:'Agent error',label:m.name,detail:m.executionStatus||m.status,color:'red'}));
102
+ blocked.forEach(x=>items.push({kind:'Blocked task',label:x.assignee||'unassigned',detail:x.content,color:'amber'}));
103
+ stopping.forEach(m=>items.push({kind:'Stopping',label:m.name,detail:'shutdown requested',color:'amber'}));
104
+ return{items,running,latest,blocked,errored};
105
+ }
106
+
73
107
  function deriveSparkline(name,msgs){
74
108
  const mine=msgs.filter(m=>m.fromName===name).map(m=>m.timeCreated).sort();
75
109
  if(mine.length<2)return '';
@@ -47,17 +47,32 @@ function rSum(t){
47
47
  );
48
48
  }
49
49
 
50
+ function rAttention(t){
51
+ const el=document.getElementById('attention'),a=deriveAttention(t),h=deriveHealth(t),tk=t.tasks||[];
52
+ const tone=a.items.length?'border-amber-500/30 bg-amber-500/[0.05]':'border-emerald-500/20 bg-emerald-500/[0.035]';
53
+ const lead=a.items.length?'Needs attention':'No blockers detected';
54
+ const chips=[chip(h.w+' working','blue'),chip(a.running.length+' active tasks','green'),chip(a.blocked.length+' blocked',a.blocked.length?'amber':'muted'),chip(tk.filter(x=>x.status==='pending').length+' pending','muted')];
55
+ let html='<div class="rounded-lg border '+tone+' px-3 py-2 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">';
56
+ html+='<div class="min-w-[180px]"><div class="text-[10px] uppercase tracking-[.16em] text-txt-500">Team attention</div><div class="text-[14px] font-semibold text-txt-100">'+lead+'</div></div>';
57
+ html+='<div class="flex flex-wrap gap-1.5">'+chips.join('')+'</div>';
58
+ if(a.items.length){html+='<div class="flex-1 grid gap-1 md:grid-cols-2">'+a.items.slice(0,4).map(x=>'<div class="text-[12px] text-txt-200 truncate">'+chip(E(x.kind),x.color)+' <span class="font-mono">'+E(x.label)+'</span> <span class="text-txt-400">'+E(x.detail)+'</span></div>').join('')+'</div>'}
59
+ else if(a.latest){html+='<div class="text-[12px] text-txt-400 truncate md:ml-auto">Latest: <span class="text-txt-200">'+E(a.latest.fromName)+'</span> '+relT(a.latest.timeCreated)+'</div>'}
60
+ html+='</div>';
61
+ patch(el,html);
62
+ }
63
+
50
64
  function rAgents(t){
51
65
  const el=document.getElementById('agents'),mm=t.members||[];
52
66
  if(!mm.length){patch(el,'<div class="col-span-full text-center py-12"><div class="text-txt-400 text-sm mb-1">No agents yet</div><div class="text-txt-500 text-[11px]">Spawn teammates with <code class="px-1 py-0.5 bg-base-900 rounded font-mono text-[10px]">team_spawn</code></div></div>');return}
53
- const n=Date.now(),msgs=t.messages||[],sorted=[...mm].sort((a,b)=>{const o={busy:0,ready:1,shutdown_requested:2,error:3,shutdown:4};return(o[a.status]??5)-(o[b.status]??5)});
67
+ const n=Date.now(),msgs=t.messages||[],sorted=[...mm].sort((a,b)=>rankAgent(a,b,t));
54
68
  const html=sorted.map((m,idx)=>{
55
- const s=si(m.status),task=(t.tasks||[]).find(x=>x.assignee===m.name&&x.status==='in_progress'),msg=msgs.find(x=>x.fromName===m.name);
69
+ const s=si(m.status),task=activeTaskFor(m.name,t.tasks||[]),msg=lastMessageFor(m.name,msgs),blocked=blockedTaskFor(m.name,t.tasks||[]);
56
70
  const d=D(n-m.timeUpdated),mi=msg?relT(msg.timeCreated):'\\u2014';
57
71
  const tt=task?.content,tr=tt&&tt.length>90?tt.slice(0,90)+'\\u2026':tt;
72
+ const mp=msg?(msg.content.length>90?msg.content.slice(0,90)+'\\u2026':msg.content):'';
58
73
  const spark=deriveSparkline(m.name,msgs);
59
74
  const isSel=selCard===idx;
60
- return '<div class="rounded-lg border '+s.c+' p-3 transition-all duration-300 cursor-pointer hover:border-base-600'+(s.dim?' opacity-50 hover:opacity-70':'')+(isSel?' card-sel':'')+'" data-card="'+E(m.name)+'" onclick="openDrawer(\\''+E(m.name)+'\\')">'+
75
+ return '<button type="button" class="text-left rounded-lg border '+s.c+' p-3 transition-all duration-300 cursor-pointer hover:border-base-600 focus-visible:border-blue-400'+(isSel?' card-sel':'')+'" data-card="'+E(m.name)+'" onclick="openDrawer(this.dataset.card)" onkeydown="if(event.key===\\'Enter\\'||event.key===\\' \\'){event.preventDefault();openDrawer(this.dataset.card)}">'+
61
76
  '<div class="flex items-center gap-2">'+
62
77
  '<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
63
78
  '<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
@@ -66,11 +81,12 @@ function rAgents(t){
66
81
  '<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
67
82
  (m.model?chip(E(m.model),'muted'):'')+
68
83
  '</div>'+
69
- (tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+E(tr)+'</div>':'')+
84
+ (tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+(blocked?'<span class="text-amber-400">Blocked: </span>':'Current task: ')+E(tr)+'</div>':'<div class="mt-2 text-[13px] text-txt-500 leading-snug">No active task</div>')+
85
+ (mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
70
86
  '<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
71
- chip(d,'muted')+chip('msg '+mi,'muted')+
87
+ chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
72
88
  (m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
73
- '</div></div>';
89
+ '</div></button>';
74
90
  }).join('');
75
91
  patch(el,html);
76
92
  }
@@ -82,8 +98,8 @@ function openDrawer(name){
82
98
  var h='';
83
99
  // Header
84
100
  h+='<div class="flex items-center justify-between mb-4">';
85
- h+='<div class="flex items-center gap-2"><span class="w-[10px] h-[10px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+'"></span><span class="font-mono font-semibold text-[16px]">'+E(m.name)+'</span><span class="text-[11px] px-2 py-[2px] rounded '+s.t+' bg-base-800/80">'+s.l+'</span></div>';
86
- h+='<button onclick="closeDrawer()" class="text-txt-500 hover:text-txt-200 transition-colors p-1"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button>';
101
+ h+='<div class="flex items-center gap-2"><span class="w-[10px] h-[10px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+'"></span><h2 id="drawer-title" class="font-mono font-semibold text-[16px]">'+E(m.name)+'</h2><span class="text-[11px] px-2 py-[2px] rounded '+s.t+' bg-base-800/80">'+s.l+'</span></div>';
102
+ h+='<button id="drawer-close" aria-label="Close agent detail" onclick="closeDrawer()" class="text-txt-500 hover:text-txt-200 transition-colors p-1"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button>';
87
103
  h+='</div>';
88
104
  // Meta chips
89
105
  var meta=[];
@@ -130,13 +146,22 @@ function openDrawer(name){
130
146
  h+='</div></div>';
131
147
  });
132
148
  }
133
- document.getElementById('drawer').innerHTML=h;
134
- document.getElementById('drawer').classList.add('open');
149
+ var drawer=document.getElementById('drawer');
150
+ drawer.innerHTML=h;
151
+ drawer.classList.add('open');
152
+ drawer.setAttribute('aria-hidden','false');
153
+ drawer.inert=false;
154
+ setBackgroundInert(true);
155
+ drawer.focus();
135
156
  document.getElementById('drawer-bg').classList.add('open');
136
157
  }
137
158
 
138
159
  function closeDrawer(){
139
- document.getElementById('drawer').classList.remove('open');
160
+ var drawer=document.getElementById('drawer');
161
+ drawer.classList.remove('open');
162
+ drawer.setAttribute('aria-hidden','true');
163
+ drawer.inert=true;
164
+ if(!modalOpen())setBackgroundInert(false);
140
165
  document.getElementById('drawer-bg').classList.remove('open');
141
166
  }
142
167
 
@@ -155,7 +180,8 @@ function rTasks(t){
155
180
  const icl=x.status==='completed'?'text-emerald-500':x.status==='in_progress'?'text-blue-400':'text-txt-500';
156
181
  const p=PR[x.priority]||PR.low,cn=x.content.length>55?x.content.slice(0,55)+'\\u2026':x.content;
157
182
  let dep='';
158
- if(x.dependsOn){const parent=taskMap[x.dependsOn];dep='<div class="text-[10px] text-txt-500 mt-0.5">\\u2514\\u2192 '+(parent?E(parent.content.slice(0,30)):E(x.dependsOn))+'</div>'}
183
+ const depIds=Array.isArray(x.dependsOn)?x.dependsOn:(x.dependsOn?[String(x.dependsOn)]:[]);
184
+ if(depIds.length){const labels=depIds.map(id=>{const parent=taskMap[id];return parent?parent.content.slice(0,42):id}).join(', ');dep='<div class="text-[10px] text-txt-500 mt-0.5">blocked by: '+E(labels)+'</div>'}
159
185
  return '<div class="flex items-start gap-2 py-1.5 px-1 rounded hover:bg-base-800/40 transition-colors">'+
160
186
  '<span class="'+icl+' text-[11px] mt-[2px] shrink-0">'+ic+'</span>'+
161
187
  '<div class="flex-1 min-w-0"><div class="text-[13px] text-txt-200 truncate">'+E(cn)+'</div>'+
@@ -182,7 +208,7 @@ function rActivity(t){
182
208
  if(!msgs.length){patch(el,'<div class="text-txt-500 text-sm py-6 text-center">Waiting for agent messages...</div>');return}
183
209
  const sp=saveSc(el);
184
210
  const newCount=msgs.length,hasNew=newCount>prevMC&&prevMC>0;
185
- let html='<div class="text-[11px] text-txt-300 font-semibold uppercase tracking-wider mb-3">Activity '+chip(msgs.length+' msgs','muted')+'</div>';
211
+ let html='<div class="flex items-center gap-2 mb-3"><span class="text-[11px] text-txt-300 font-semibold uppercase tracking-wider">Activity feed</span>'+chip(msgs.length+' msgs','muted')+'</div>';
186
212
  html+='<div class="max-h-[50vh] overflow-y-auto scroll space-y-2">';
187
213
  msgs.slice(0,30).forEach(function(m,mi){
188
214
  const isNew=hasNew&&mi<(newCount-prevMC);
@@ -195,7 +221,7 @@ function rActivity(t){
195
221
  const bubbleBg=isPeer?'bg-violet-500/[0.06] border-violet-500/15':isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
196
222
  const initial=m.fromName.charAt(0).toUpperCase();
197
223
  const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isPeer?'bg-violet-500/20 text-violet-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
198
- html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" data-msg="'+E(m.id)+'" onclick="toggleMsg(\\''+E(m.id)+'\\')">';
224
+ html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" role="button" tabindex="0" aria-expanded="'+(isExp?'true':'false')+'" data-msg="'+E(m.id)+'" onclick="toggleMsg(this.dataset.msg)" onkeydown="if(event.key===\\'Enter\\'||event.key===\\' \\'){event.preventDefault();toggleMsg(this.dataset.msg)}">';
199
225
  html+='<div class="flex items-start gap-2">';
200
226
  html+='<span class="w-5 h-5 rounded-full '+avatarColor+' flex items-center justify-center text-[9px] font-semibold shrink-0 mt-0.5">'+E(initial)+'</span>';
201
227
  html+='<div class="flex-1 min-w-0 rounded-xl border '+bubbleBg+' px-3 py-2">';
@@ -7,16 +7,49 @@ function render(){
7
7
  const empty=document.getElementById('empty'),content=document.getElementById('content');
8
8
  if(!t){empty.classList.remove('hidden');empty.classList.add('flex');content.classList.add('hidden');document.getElementById('tl').classList.add('hidden');return}
9
9
  empty.classList.add('hidden');empty.classList.remove('flex');content.classList.remove('hidden');
10
- rHealth(t);rSum(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
10
+ rHealth(t);rSum(t);rAttention(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
11
11
  }
12
12
 
13
13
  function conn(ok){
14
14
  document.getElementById('cd').className='w-[7px] h-[7px] rounded-full '+(ok?'bg-emerald-500 pulse':'bg-red-500');
15
- document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'disconnected';
15
+ document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'reconnecting to dashboard state';
16
16
  }
17
17
 
18
18
  async function poll(){try{S=await(await fetch('/api/state')).json();fails=0;pollT=Date.now();conn(true);render()}catch{if(++fails>=3)conn(false)}}
19
19
 
20
+ function setBackgroundInert(locked){
21
+ document.querySelectorAll('header,main,#sum,#tl').forEach(function(el){
22
+ el.inert=locked;
23
+ if(locked)el.setAttribute('aria-hidden','true');
24
+ else el.removeAttribute('aria-hidden');
25
+ });
26
+ }
27
+
28
+ function modalOpen(){return document.getElementById('sco').classList.contains('show')||document.getElementById('drawer').classList.contains('open')}
29
+
30
+ function trapFocus(root,e){
31
+ const nodes=[...root.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(function(el){return !el.disabled&&!el.inert&&el.offsetParent!==null});
32
+ if(!nodes.length){e.preventDefault();root.focus();return}
33
+ const first=nodes[0],last=nodes[nodes.length-1];
34
+ if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();return}
35
+ if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();return}
36
+ }
37
+
38
+ function openShortcuts(){
39
+ const el=document.getElementById('sco');
40
+ setBackgroundInert(true);
41
+ el.classList.add('show');
42
+ el.setAttribute('aria-hidden','false');
43
+ document.getElementById('sco').focus();
44
+ }
45
+
46
+ function closeShortcuts(){
47
+ const el=document.getElementById('sco');
48
+ el.classList.remove('show');
49
+ el.setAttribute('aria-hidden','true');
50
+ if(!modalOpen())setBackgroundInert(false);
51
+ }
52
+
20
53
  // Clock update every second
21
54
  setInterval(function(){var t=cur();if(t)rClock(t);if(fails<3)conn(true)},1000);
22
55
 
@@ -25,11 +58,18 @@ setInterval(poll,2500);
25
58
 
26
59
  // Keyboard shortcuts
27
60
  document.addEventListener('keydown',function(e){
61
+ const shortcutsOpen=document.getElementById('sco').classList.contains('show');
62
+ if(shortcutsOpen&&e.key==='Tab'){trapFocus(document.getElementById('sco'),e);return}
63
+ if(shortcutsOpen&&e.key==='Escape'){e.preventDefault();closeShortcuts();return}
64
+ const drawerOpen=document.getElementById('drawer').classList.contains('open');
65
+ if(drawerOpen&&e.key==='Tab'){trapFocus(document.getElementById('drawer'),e);return}
66
+ if(drawerOpen&&e.key==='Escape'){e.preventDefault();closeDrawer();return}
67
+ if(drawerOpen&&e.key==='?'){e.preventDefault();return}
28
68
  if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT'||e.target.tagName==='TEXTAREA')return;
29
69
  var t=cur();if(!t)return;
30
- var mm=t.members||[];
31
- if(e.key==='?'){e.preventDefault();document.getElementById('sco').classList.toggle('show');return}
32
- if(e.key==='Escape'){closeDrawer();expMsgs.clear();selCard=-1;document.getElementById('sco').classList.remove('show');render();return}
70
+ var mm=[...(t.members||[])].sort((a,b)=>rankAgent(a,b,t));
71
+ if(e.key==='?'){e.preventDefault();shortcutsOpen?closeShortcuts():openShortcuts();return}
72
+ if(e.key==='Escape'){closeDrawer();expMsgs.clear();selCard=-1;closeShortcuts();render();return}
33
73
  if(e.key==='j'&&mm.length){e.preventDefault();selCard=Math.min(selCard+1,mm.length-1);render();return}
34
74
  if(e.key==='k'&&mm.length){e.preventDefault();selCard=Math.max(selCard-1,0);render();return}
35
75
  if(e.key==='Enter'&&selCard>=0&&selCard<mm.length){e.preventDefault();openDrawer(mm[selCard].name);return}
package/src/dashboard.ts CHANGED
@@ -58,6 +58,21 @@ function jsonResponse(data: unknown, status = 200): Response {
58
58
  })
59
59
  }
60
60
 
61
+ function parseDependsOn(value: string | null): string[] {
62
+ if (!value) return []
63
+
64
+ try {
65
+ const parsed: unknown = JSON.parse(value)
66
+
67
+ if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
68
+ if (typeof parsed === "string") return [parsed]
69
+ } catch {
70
+ return [value]
71
+ }
72
+
73
+ return []
74
+ }
75
+
61
76
  function buildState(db: Database): { teams: unknown[] } {
62
77
  const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
63
78
  const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
@@ -90,7 +105,7 @@ function buildState(db: Database): { teams: unknown[] } {
90
105
  status: tk.status,
91
106
  priority: tk.priority,
92
107
  assignee: tk.assignee,
93
- dependsOn: tk.depends_on,
108
+ dependsOn: parseDependsOn(tk.depends_on),
94
109
  timeCreated: tk.time_created,
95
110
  timeUpdated: tk.time_updated,
96
111
  })),
@@ -135,6 +135,8 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
135
135
  "",
136
136
  "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.",
137
137
  "This avoids git worktree contention. Once all are spawned, wait for their messages.",
138
+ "Read-only agents (explore, plan) automatically skip worktree creation.",
139
+ "For other agents that only need to read, pass worktree: false to avoid unnecessary isolation.",
138
140
  "",
139
141
  "Teammates work asynchronously and message you when done.",
140
142
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
@@ -85,7 +85,8 @@ export async function executeTeamSpawn(
85
85
  .get(teamInfo.teamId, args.name)
86
86
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
87
87
 
88
- const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory)
88
+ const isReadOnly = args.agent === "plan" || args.agent === "explore"
89
+ const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
89
90
  const usePlanApproval = args.plan_approval === true
90
91
 
91
92
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
@@ -143,7 +144,6 @@ export async function executeTeamSpawn(
143
144
  // Permission rules on session.create are the hard gate (server-enforced).
144
145
  // For read-only agents, deny write tools and explicitly allow team tools.
145
146
  // For all agents with worktrees, allowlist the worktree path for edit/bash.
146
- const isReadOnly = args.agent === "plan" || args.agent === "explore"
147
147
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
148
148
  const permission: PermissionRule[] = []
149
149