@hueyexe/opencode-ensemble 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
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",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./rpc": "./src/rpc.ts",
10
+ "./tui": "./src/tui.tsx"
11
+ },
7
12
  "type": "module",
8
13
  "license": "MIT",
9
14
  "repository": {
@@ -30,6 +35,7 @@
30
35
  "lint": "bunx biome lint src/",
31
36
  "typecheck": "tsc --noEmit",
32
37
  "test": "bun test",
38
+ "eval:smoke": "bun eval/smoke.ts",
33
39
  "build": "bun build src/index.ts --outdir dist --target node",
34
40
  "prepublishOnly": "bun run typecheck && bun test && bun run build"
35
41
  },
@@ -39,13 +45,20 @@
39
45
  },
40
46
  "devDependencies": {
41
47
  "@biomejs/biome": "^2.4.9",
42
- "@types/bun": "latest"
48
+ "@opentui/core": "0.5.10",
49
+ "@opentui/solid": "0.5.10",
50
+ "@types/bun": "latest",
51
+ "solid-js": "1.9.0"
43
52
  },
44
53
  "peerDependencies": {
45
- "typescript": "^5"
54
+ "typescript": "^5",
55
+ "@opentui/core": ">=0.5.10",
56
+ "@opentui/solid": ">=0.5.10",
57
+ "solid-js": ">=1.9.0"
46
58
  },
47
59
  "dependencies": {
48
60
  "@opencode-ai/plugin": "^1.17.18",
49
- "@opencode-ai/sdk": "^1.17.18"
61
+ "@opencode-ai/sdk": "^1.17.18",
62
+ "@opencode/plugin": "2.0.3"
50
63
  }
51
- }
64
+ }
@@ -81,7 +81,7 @@ function rAgents(t){
81
81
  '<div class="flex items-center gap-2">'+
82
82
  '<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
83
83
  '<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
84
- '<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
84
+ '<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+(m.lastNudgedAt?', nudged '+relT(m.lastNudgedAt):'')+'</span>'+
85
85
  spark+
86
86
  '<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
87
87
  (m.model?chip(E(m.model),'muted'):'')+
@@ -90,6 +90,7 @@ function rAgents(t){
90
90
  (mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
91
91
  '<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
92
92
  chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
93
+ (m.isRetrying?chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':''),'amber'):'')+
93
94
  (m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
94
95
  '</div></button>';
95
96
  }).join('');
@@ -113,6 +114,8 @@ function openDrawer(name){
113
114
  meta.push(chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted'));
114
115
  if(m.planApproval&&m.planApproval!=='none')meta.push(chip(E(m.planApproval),m.planApproval==='approved'?'green':m.planApproval==='rejected'?'red':'amber'));
115
116
  meta.push(chip('spawned '+relT(m.timeCreated),'muted'));
117
+ if(m.lastNudgedAt)meta.push(chip('nudged '+relT(m.lastNudgedAt),'amber'));
118
+ if(m.isRetrying)meta.push(chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':'')+(m.retryMessage?': '+E(m.retryMessage):''),'amber'));
116
119
  if(m.worktreeBranch)meta.push(chip(E(m.worktreeBranch),'muted'));
117
120
  h+='<div class="flex flex-wrap gap-1.5 mb-4 pb-4 border-b border-base-800/50">'+meta.join('')+'</div>';
118
121
  // Prompt
@@ -180,7 +183,7 @@ function rDrawerActivityUpdate(){
180
183
  el.innerHTML='<div class="text-txt-500 text-[12px]">No activity recorded</div>';
181
184
  return;
182
185
  }
183
- var html=drawerActivity.map(function(a){
186
+ var html=drawerActivity.slice().reverse().map(function(a){
184
187
  var icon='\\u25CF',color='text-txt-500',label=a.type;
185
188
  if(a.type==='tool_call'){icon='\\u25B8';color='text-blue-400';label=a.tool||'tool'}
186
189
  if(a.type==='tool_result'){icon='\\u25B8';color=a.error?'text-red-400':'text-emerald-400';label=a.tool||'tool'}
package/src/dashboard.ts CHANGED
@@ -16,6 +16,7 @@ interface TeamRow {
16
16
  id: string
17
17
  name: string
18
18
  project_id: string
19
+ lead_session_id: string
19
20
  status: string
20
21
  lead_agent: string | null
21
22
  time_created: number
@@ -43,6 +44,11 @@ interface MemberRow {
43
44
  plan_approval: string
44
45
  time_created: number
45
46
  time_updated: number
47
+ last_nudged_at: number | null
48
+ retry_until: number | null
49
+ retry_attempt: number | null
50
+ retry_provider: string | null
51
+ retry_message: string | null
46
52
  }
47
53
 
48
54
  interface TaskRow {
@@ -81,10 +87,31 @@ function parseDependsOn(value: string | null): string[] {
81
87
  return []
82
88
  }
83
89
 
84
- function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
90
+ /**
91
+ * Bare-integer version of the `/api/state` response shape. Bump on any
92
+ * breaking change to the payload (field removed/renamed/retyped, not
93
+ * additive fields). Consumers outside this repo compare this field with
94
+ * strict equality — no semver ranges. Currently consumed externally by the
95
+ * OpenCode sidebar TUI plugin that renders Team status from this endpoint.
96
+ */
97
+ export const ENSEMBLE_STATE_VERSION = 1
98
+
99
+ /**
100
+ * `/api/state` response shape. Exported and versioned because at least one
101
+ * consumer outside this repo (an OpenCode sidebar TUI plugin) polls this
102
+ * endpoint and depends on its shape. Treat additive-only changes as safe;
103
+ * bump {@link ENSEMBLE_STATE_VERSION} for anything else.
104
+ */
105
+ export interface EnsembleDashboardState {
106
+ version: number
107
+ projects: unknown[]
108
+ teams: unknown[]
109
+ }
110
+
111
+ function buildState(db: Database): EnsembleDashboardState {
85
112
  const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
86
- const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
87
- const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
113
+ const teams = db.query("SELECT id, name, project_id, lead_session_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
114
+ const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated, last_nudged_at, retry_until, retry_attempt, retry_provider, retry_message FROM team_member WHERE team_id = ?")
88
115
  const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
89
116
  const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
90
117
 
@@ -101,11 +128,21 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
101
128
  planApproval: m.plan_approval,
102
129
  timeCreated: m.time_created,
103
130
  timeUpdated: m.time_updated,
131
+ lastNudgedAt: m.last_nudged_at,
132
+ // Fix 4: derived, read-time TTL boolean (Fix 3) — never a stored enum.
133
+ // Additive fields; existing consumers that don't know about them simply
134
+ // don't render them.
135
+ isRetrying: m.retry_until !== null && m.retry_until > Date.now(),
136
+ retryUntil: m.retry_until,
137
+ retryAttempt: m.retry_attempt,
138
+ retryProvider: m.retry_provider,
139
+ retryMessage: m.retry_message,
104
140
  }))
105
141
  return {
106
142
  id: t.id,
107
143
  name: t.name,
108
144
  projectId: t.project_id,
145
+ leadSessionId: t.lead_session_id,
109
146
  status: t.status,
110
147
  leadAgent: t.lead_agent,
111
148
  timeCreated: t.time_created,
@@ -140,6 +177,7 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
140
177
  })
141
178
 
142
179
  return {
180
+ version: ENSEMBLE_STATE_VERSION,
143
181
  projects: projects.flatMap(project => {
144
182
  const projectTeams = teamsByProject.get(project.id) ?? []
145
183
  if (projectTeams.length === 0) return []
@@ -183,11 +221,30 @@ function sendJson(res: ServerResponse, data: unknown): void {
183
221
  res.end(JSON.stringify(data))
184
222
  }
185
223
 
224
+ /**
225
+ * Resolve a message's creation timestamp (Unix ms) from an SDK message's
226
+ * `info.time`. The SDK shape is an object `{ created: number }`; older/other
227
+ * shapes (a numeric epoch or an ISO string) are tolerated. Falls back to
228
+ * `Date.now()` when the value is missing or unparseable — never returns NaN.
229
+ */
230
+ export function parseMessageTime(time: unknown): number {
231
+ if (typeof time === "object" && time !== null) {
232
+ const created = (time as { created?: unknown }).created
233
+ if (typeof created === "number" && Number.isFinite(created)) return created
234
+ }
235
+ if (typeof time === "number" && Number.isFinite(time)) return time
236
+ if (typeof time === "string") {
237
+ const ms = new Date(time).getTime()
238
+ if (!Number.isNaN(ms)) return ms
239
+ }
240
+ return Date.now()
241
+ }
242
+
186
243
  /** Parse SDK message parts into ActivityEntry format for the fallback path. */
187
244
  export function parseMessageParts(parts: unknown[], msgInfo: unknown): ActivityEntry[] {
188
245
  const entries: ActivityEntry[] = []
189
- const info = (msgInfo ?? {}) as { time?: string; role?: string; tokens?: { input?: number; output?: number } }
190
- const timestamp = info.time ? new Date(info.time).getTime() : Date.now()
246
+ const info = (msgInfo ?? {}) as { time?: unknown; role?: string; tokens?: { input?: number; output?: number } }
247
+ const timestamp = parseMessageTime(info.time)
191
248
 
192
249
  for (const part of parts) {
193
250
  if (typeof part !== "object" || part === null) continue
package/src/hooks.ts CHANGED
@@ -1,10 +1,38 @@
1
1
  import type { Database } from "./db"
2
2
  import type { MemberRegistry, DescendantTracker } from "./state"
3
- import { sendMessage } from "./messaging"
3
+ import type { PluginClient } from "./types"
4
+ import { notifyLead } from "./notify"
5
+ import { releaseMemberTasks } from "./tasks"
4
6
  import { findTeamBySession } from "./types"
5
7
 
6
8
  const TEAM_TOOL_PREFIX = "team_"
7
9
 
10
+ /**
11
+ * Retry-specific payload carried by a `session.status` event when
12
+ * `status === "retry"`. Mirrors the SDK's `SessionStatus` retry variant.
13
+ * `action` is optional and free-form (not rate-limit-specific) — see Fix 2
14
+ * in the retry-observability spec.
15
+ */
16
+ export interface RetryStatusPayload {
17
+ attempt: number
18
+ message: string
19
+ action?: {
20
+ reason: string
21
+ provider: string
22
+ title: string
23
+ message: string
24
+ label: string
25
+ link?: string
26
+ }
27
+ /**
28
+ * Absolute epoch-ms timestamp of the next retry attempt — NOT a relative
29
+ * delta. Confirmed against the opencode server/TUI source (session.retry.scheduled
30
+ * emits `{attempt, at, error}` mapped straight onto `next`, and the built-in
31
+ * TUI computes remaining seconds as `Math.round((next - Date.now()) / 1000)`).
32
+ */
33
+ next: number
34
+ }
35
+
8
36
  /** Result of a session status event — tells the caller what transition happened. */
9
37
  export interface StatusTransition {
10
38
  memberName: string
@@ -18,12 +46,17 @@ export interface StatusTransition {
18
46
  * in SQLite based on the new session status.
19
47
  * Ignores events for unknown sessions or archived teams.
20
48
  * Returns the transition if one occurred, for toast notifications.
49
+ *
50
+ * `retryPayload` is only consulted when `status === "retry"` — it persists
51
+ * the four additive retry_* columns (Fix 2) without changing `status`/
52
+ * `execution_status` at all (Fix 1's non-goal: no FSM change).
21
53
  */
22
54
  export function handleSessionStatusEvent(
23
55
  db: Database,
24
56
  registry: MemberRegistry,
25
57
  sessionId: string,
26
58
  status: "idle" | "busy" | "retry",
59
+ retryPayload?: RetryStatusPayload,
27
60
  ): StatusTransition | undefined {
28
61
  const entry = registry.getBySession(sessionId)
29
62
  if (!entry) return undefined
@@ -43,6 +76,11 @@ export function handleSessionStatusEvent(
43
76
  "UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
44
77
  [newStatus, Date.now(), entry.teamId, entry.memberName]
45
78
  )
79
+ // A gracefully shut-down member has actually stopped now — release any
80
+ // tasks they left in_progress back to the pool so nothing is stranded (issue #27).
81
+ if (newStatus === "shutdown") {
82
+ releaseMemberTasks(db, entry.teamId, entry.memberName)
83
+ }
46
84
  // Mark teammate as having reported if they sent at least one message to lead (issue #3).
47
85
  // Set on busy→ready transition so Q&A messages during work don't prematurely block delivery.
48
86
  if (member.status === "busy" && newStatus === "ready") {
@@ -67,12 +105,46 @@ export function handleSessionStatusEvent(
67
105
  )
68
106
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
69
107
  }
108
+ // Freshly-spawned member: inserted as busy/starting before the session's
109
+ // first busy event. Advance execution_status to running and refresh
110
+ // time_updated so the dashboard shows accurate progress instead of a
111
+ // frozen "starting" state (do NOT reset reported_to_lead — status is
112
+ // already busy, so this is not a re-activation).
113
+ if (member.status === "busy" && member.execution_status === "starting") {
114
+ db.run(
115
+ "UPDATE team_member SET execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?",
116
+ [Date.now(), entry.teamId, entry.memberName]
117
+ )
118
+ return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
119
+ }
70
120
  // Session went busy while shutdown was requested — signal for re-abort
71
121
  if (member.status === "shutdown_requested") {
72
122
  return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" }
73
123
  }
74
124
  } else if (status === "retry") {
75
- // Session is being rate-limited — signal for toast but don't change state
125
+ // Session is being rate-limited — signal for toast but don't change state.
126
+ // Persist the retry_* columns so the signal survives past the toast (Fix 2)
127
+ // — status/execution_status are untouched, and there is no "provider
128
+ // throttled" language baked in here: the SDK's retry status is generic,
129
+ // so we surface whatever action.message/status.message actually says.
130
+ if (retryPayload) {
131
+ // Coerce before binding: older server versions may emit a partial retry
132
+ // payload, and an undefined bind throws synchronously inside this event
133
+ // handler, discarding the event's remaining processing.
134
+ const next = typeof retryPayload.next === "number" && Number.isFinite(retryPayload.next) ? retryPayload.next : null
135
+ const attempt = typeof retryPayload.attempt === "number" && Number.isFinite(retryPayload.attempt) ? retryPayload.attempt : null
136
+ db.run(
137
+ "UPDATE team_member SET retry_until = ?, retry_attempt = ?, retry_provider = ?, retry_message = ? WHERE team_id = ? AND name = ?",
138
+ [
139
+ next,
140
+ attempt,
141
+ retryPayload.action?.provider ?? null,
142
+ retryPayload.action?.message ?? retryPayload.message ?? null,
143
+ entry.teamId,
144
+ entry.memberName,
145
+ ]
146
+ )
147
+ }
76
148
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" }
77
149
  }
78
150
  return undefined
@@ -169,6 +241,11 @@ export interface SessionErrorPayload {
169
241
  * Handle a session.error event. Surfaces tool/model failures from a teammate
170
242
  * as a system message to the lead, so otherwise-silent failures are visible.
171
243
  *
244
+ * The lead is actively woken via notifyLead so the message is delivered even
245
+ * when the errored teammate was the last busy member (the "all done" gate in
246
+ * the passive wake path would otherwise strand the message). The teammate
247
+ * itself is NOT auto-resumed — recovery is left to the lead's judgement.
248
+ *
172
249
  * Ignored when:
173
250
  * - sessionID is undefined
174
251
  * - the session is not a registered teammate (leads are not in the registry)
@@ -176,6 +253,7 @@ export interface SessionErrorPayload {
176
253
  export function handleSessionErrorEvent(
177
254
  db: Database,
178
255
  registry: MemberRegistry,
256
+ client: PluginClient,
179
257
  sessionId: string | undefined,
180
258
  error: SessionErrorPayload | undefined,
181
259
  ): void {
@@ -187,10 +265,10 @@ export function handleSessionErrorEvent(
187
265
  if (!teamInfo || teamInfo.role !== "member" || !teamInfo.memberName) return
188
266
 
189
267
  const errMsg = error?.data?.message ?? error?.name ?? "unknown error"
190
- sendMessage(db, {
191
- teamId: teamInfo.teamId,
192
- from: "system",
193
- to: "lead",
194
- content: `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`,
195
- })
268
+ notifyLead(
269
+ client,
270
+ db,
271
+ teamInfo.teamId,
272
+ `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`,
273
+ )
196
274
  }
package/src/index.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
+ import { Plugin as PluginV2 } from "@opencode/plugin"
3
+ import { setupEnsemble, type V2SetupContext } from "./v2-setup"
2
4
  import { tool } from "@opencode-ai/plugin"
3
5
  import { OpencodeClient } from "@opencode-ai/sdk/v2"
4
6
  import path from "node:path"
5
7
  import { mkdirSync } from "node:fs"
6
8
  import { createDb, getDbPath } from "./db"
7
9
  import { wrapThrowingClient } from "./client"
8
- import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, rehydrateRegistry } from "./recovery"
10
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, recoverOrphanedTeams, rehydrateRegistry } from "./recovery"
9
11
  import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
10
12
  import { isWorktreeInstance } from "./util"
11
13
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember, handleSessionErrorEvent } from "./hooks"
12
- import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
- import { sendMessage, hasReportedCompletion } from "./messaging"
14
+ import { notifyTeamEvent, notifyWorkingProgress, notifyLead } from "./notify"
15
+ import { hasReportedCompletion } from "./messaging"
16
+ import { getMemberModel } from "./member-model"
14
17
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
15
18
  import { log, initLog } from "./log"
16
19
  import { findTeamBySession } from "./types"
@@ -72,13 +75,35 @@ const plugin: Plugin = async (input) => {
72
75
  const rawClient = new OpencodeClient({ client: pluginTransport })
73
76
  initLog(rawClient)
74
77
  const client = wrapThrowingClient(rawClient)
75
- const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config }
78
+ const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config, progressTracker }
76
79
 
77
80
  // Recovery only runs for the main project instance — NOT for teammate worktree instances.
78
81
  // Worktree instances are created during session.create. Running recovery there makes HTTP
79
82
  // calls back to the server, which deadlocks because the server is still handling session.create.
80
83
  if (!isWorktreeInstance(input.directory)) {
81
84
  log("init:recovery:start (main instance)")
85
+
86
+ // Reconciles teams whose lead session was deleted externally (not via
87
+ // team_cleanup) -- otherwise they stay 'active' forever, blocking
88
+ // team_create/team_cleanup for that name across restarts. See
89
+ // recoverOrphanedTeams' own doc comment for the full mechanism.
90
+ //
91
+ // Fire-and-forget, NOT awaited: isSessionAlive() calls client.session.get(),
92
+ // an HTTP call back to this same server. Awaiting it synchronously here --
93
+ // before the server has finished bootstrapping -- reproduced a real,
94
+ // confirmed deadlock (server never responds to ANY request, including
95
+ // unrelated ones like /config) when a stale team from a prior run exists
96
+ // for this project. Matches the existing pattern for the other three
97
+ // non-critical recovery passes below, and the documented reason
98
+ // recoverStaleMembers is skipped entirely for worktree instances two lines
99
+ // up ("makes HTTP calls back to the server, which deadlocks"). isSessionAlive
100
+ // itself also carries a bounded timeout as defense in depth.
101
+ recoverOrphanedTeams(db, client, input.directory, registry).then((result) => {
102
+ if (result.archived > 0) log(`init:recovery:orphaned-teams-archived=${result.archived}`)
103
+ }).catch((err) => {
104
+ log(`init:recover-orphaned-teams:failed err=${err instanceof Error ? err.message : String(err)}`)
105
+ })
106
+
82
107
  const recovery = await recoverStaleMembers(db, client, input.directory)
83
108
  if (recovery.interrupted > 0) {
84
109
  log(`init:recovery:interrupted=${recovery.interrupted}`)
@@ -142,7 +167,10 @@ const plugin: Plugin = async (input) => {
142
167
  if (event.type === "session.status") {
143
168
  const { sessionID, status } = event.properties
144
169
  const statusType = status.type as "idle" | "busy" | "retry"
145
- const transition = handleSessionStatusEvent(db, registry, sessionID, statusType)
170
+ const retryPayload = statusType === "retry"
171
+ ? (status as { attempt: number; message: string; action?: { reason: string; provider: string; title: string; message: string; label: string; link?: string }; next: number })
172
+ : undefined
173
+ const transition = handleSessionStatusEvent(db, registry, sessionID, statusType, retryPayload)
146
174
 
147
175
  // Fire toast notifications for meaningful transitions
148
176
  if (transition) {
@@ -167,12 +195,12 @@ const plugin: Plugin = async (input) => {
167
195
  nudgedMembers.add(fastIdleKey)
168
196
  const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : ""
169
197
  log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`)
170
- sendMessage(db, {
171
- teamId: transition.teamId,
172
- from: "system",
173
- to: "lead",
174
- content: `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`,
175
- })
198
+ notifyLead(
199
+ client,
200
+ db,
201
+ transition.teamId,
202
+ `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`,
203
+ )
176
204
  client.tui.showToast({
177
205
  title: "Team",
178
206
  message: `${transition.memberName} failed to produce output${modelInfo}`,
@@ -189,13 +217,23 @@ const plugin: Plugin = async (input) => {
189
217
  if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
190
218
  nudgedMembers.add(nudgeKey)
191
219
  log(`nudge:idle-without-report name=${transition.memberName}`)
220
+ const nudgeModel = getMemberModel(db, transition.teamId, transition.memberName)
192
221
  client.session.promptAsync({
193
222
  sessionID,
194
223
  parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
195
- }).catch(() => { /* best effort */ })
224
+ ...(nudgeModel ? { model: nudgeModel } : {}),
225
+ }).catch((err) => {
226
+ log(`nudge:idle-without-report:failed name=${transition.memberName} team=${transition.teamId} err=${err instanceof Error ? err.message : String(err)}`)
227
+ })
196
228
  }
197
229
  } else if (transition.to === "error") {
198
230
  notifyTeamEvent(client, "error", { memberName: transition.memberName })
231
+ } else if (transition.to === "busy") {
232
+ // Fresh busy period (ready/error -> busy) — give checkStalled a baseline
233
+ // that doesn't depend on a step-finish event having landed yet. Without
234
+ // this, a member whose first action is one long-running tool call is
235
+ // never detected as stalled until that call itself returns.
236
+ progressTracker.recordBusyStart(sessionID)
199
237
  } else if (transition.to === "retry") {
200
238
  // Teammate is being rate-limited — notify user
201
239
  try {
@@ -270,9 +308,11 @@ const plugin: Plugin = async (input) => {
270
308
  ).get(member.team_id, member.name, staleThreshold) as { c: number }
271
309
  if (peerMsgs.c > 0) {
272
310
  log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
311
+ const peerModel = getMemberModel(db, member.team_id, member.name)
273
312
  client.session.promptAsync({
274
313
  sessionID,
275
314
  parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
315
+ ...(peerModel ? { model: peerModel } : {}),
276
316
  }).catch((err) => {
277
317
  log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
278
318
  })
@@ -294,7 +334,7 @@ const plugin: Plugin = async (input) => {
294
334
  // teammate just appears stuck.
295
335
  if (event.type === "session.error") {
296
336
  const props = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
297
- handleSessionErrorEvent(db, registry, props.sessionID, props.error)
337
+ handleSessionErrorEvent(db, registry, client, props.sessionID, props.error)
298
338
  }
299
339
 
300
340
  // Track per-step output tokens for stall detection
@@ -415,12 +455,16 @@ const plugin: Plugin = async (input) => {
415
455
  }),
416
456
 
417
457
  team_message: tool({
418
- description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
458
+ description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
459
+ "Lead only: pass 'model' (provider/model) to update a teammate's model in-place — it applies on their next turn. " +
460
+ "When updating a model, 'text' is optional; omit it to change the model without sending a message.",
419
461
  args: {
420
462
  to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
421
- text: tool.schema.string().describe("Message content (max 10KB)"),
463
+ text: tool.schema.string().optional().describe("Message content (max 10KB). Optional only when 'model' is provided."),
422
464
  approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
423
465
  reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
466
+ force: tool.schema.boolean().optional().describe("Lead only. Re-activate a teammate who already reported task completion (normally their session won't be woken again). Use for legitimate follow-on work — e.g. the next round of a multi-round debate — not for courtesy replies."),
467
+ model: tool.schema.string().optional().describe("Lead only: update the recipient teammate's model in-place, in 'provider/model' format (e.g. 'anthropic/claude-sonnet')."),
424
468
  },
425
469
  async execute(args, ctx) {
426
470
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
@@ -428,7 +472,7 @@ const plugin: Plugin = async (input) => {
428
472
  progressTracker.recordMessage(ctx.sessionID)
429
473
  // Track peer messages for chatty detection
430
474
  if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
431
- ctx.metadata({ title: `Message → ${args.to}` })
475
+ ctx.metadata({ title: args.model ? `Set ${args.to} model → ${args.model}` : `Message → ${args.to}` })
432
476
  return result
433
477
  },
434
478
  }),
@@ -440,8 +484,13 @@ const plugin: Plugin = async (input) => {
440
484
  },
441
485
  async execute(args, ctx) {
442
486
  const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
443
- // Track broadcast activity for stall detection
487
+ // Track broadcast activity for stall detection AND chatty detection --
488
+ // a broadcast reaches every teammate at once, arguably the most
489
+ // "chatty" action possible, and was previously invisible to
490
+ // checkChatty()'s peer-message rate limit (recordPeerMessage was never
491
+ // called here, only recordMessage).
444
492
  progressTracker.recordMessage(ctx.sessionID)
493
+ progressTracker.recordPeerMessage(ctx.sessionID)
445
494
  ctx.metadata({ title: "Broadcast to team" })
446
495
  return result
447
496
  },
@@ -609,10 +658,11 @@ const plugin: Plugin = async (input) => {
609
658
  }),
610
659
 
611
660
  team_view: tool({
612
- description: "Navigate the TUI to a teammate's session so you can see what they are doing. " +
613
- "Use the session picker (ctrl+p) to return to the lead session.",
661
+ description: "Resolve a teammate's session so the user can inspect it. Default reports the session ID + status without switching. " +
662
+ "Pass navigate: true ONLY when the user explicitly asked to view the teammate's session — it switches their client to it. Return via the session picker (ctrl+p).",
614
663
  args: {
615
664
  member: tool.schema.string().describe("Teammate name to view"),
665
+ navigate: tool.schema.boolean().default(false).describe("Switch the user's client to this session. Only when the user explicitly asked."),
616
666
  },
617
667
  async execute(args, ctx) {
618
668
  const result = await executeTeamView(deps, args, ctx.sessionID)
@@ -624,4 +674,24 @@ const plugin: Plugin = async (input) => {
624
674
  }
625
675
  }
626
676
 
627
- export default plugin
677
+ /**
678
+ * Dual V1/V2 export (issue #36).
679
+ * - V2 reads `id` + `setup()` and ignores `server()`.
680
+ * - V1 (>=1.18.29) calls `server()` and ignores the V2 fields.
681
+ * The V2 setup body lands in later phases; the V1 implementation below is
682
+ * unchanged so current users keep working during the port.
683
+ */
684
+ export default {
685
+ ...PluginV2.define({
686
+ id: "ensemble",
687
+ async setup(ctx) {
688
+ // Dual support (issue #36): V2 initializes here; the V1 server()
689
+ // below keeps serving V1 hosts. Tool registration lands next slice.
690
+ const handle = await setupEnsemble(ctx as unknown as V2SetupContext)
691
+ return () => {
692
+ void handle.dispose()
693
+ }
694
+ },
695
+ }),
696
+ server: plugin,
697
+ }
package/src/log.ts CHANGED
@@ -11,3 +11,12 @@ export function log(msg: string): void {
11
11
  if (!_client) return
12
12
  _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {})
13
13
  }
14
+
15
+ /**
16
+ * V2 fallback logger. The V2 plugin context exposes no app.log, so server
17
+ * log lines go to stdout with a prefix (the host captures plugin stdout
18
+ * into its own log). Used only by V2 setup paths.
19
+ */
20
+ export function vlog(msg: string): void {
21
+ console.log(`[ensemble] ${msg}`)
22
+ }
@@ -0,0 +1,30 @@
1
+ import type { Database } from "./db"
2
+
3
+ /** A parsed provider/model pair, as accepted by session.promptAsync. */
4
+ export interface ParsedModel {
5
+ providerID: string
6
+ modelID: string
7
+ }
8
+
9
+ /**
10
+ * Parse a "provider/model" string into { providerID, modelID } for the SDK.
11
+ * Returns undefined if the string is not in "provider/model" form (empty
12
+ * provider or empty model). The model portion may itself contain slashes.
13
+ */
14
+ export function parseModelId(model: string): ParsedModel | undefined {
15
+ const slash = model.indexOf("/")
16
+ if (slash <= 0 || slash === model.length - 1) return undefined
17
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
18
+ }
19
+
20
+ /**
21
+ * Read a team member's stored model and parse it for session.promptAsync.
22
+ * Returns undefined when the member is unknown, has no model set, or the
23
+ * stored value is malformed. Never throws — safe to call from delivery paths.
24
+ */
25
+ export function getMemberModel(db: Database, teamId: string, memberName: string): ParsedModel | undefined {
26
+ const row = db.query("SELECT model FROM team_member WHERE team_id = ? AND name = ?")
27
+ .get(teamId, memberName) as { model: string | null } | null
28
+ if (!row?.model) return undefined
29
+ return parseModelId(row.model)
30
+ }