@hueyexe/opencode-ensemble 0.17.0 → 0.20.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/README.md +57 -35
- package/dist/index.js +28760 -19583
- package/package.json +18 -5
- package/src/index.ts +26 -3
- package/src/log.ts +9 -0
- package/src/notify.ts +3 -1
- package/src/rpc.ts +99 -0
- package/src/tool-arg-hint.ts +71 -0
- package/src/tools/team-message.ts +1 -0
- package/src/tools/team-spawn.ts +48 -4
- package/src/tools/team-tasks-add.ts +17 -0
- package/src/tools/team-view.ts +10 -4
- package/src/tui-view.ts +50 -0
- package/src/tui.tsx +101 -0
- package/src/types.ts +6 -0
- package/src/v2-client.ts +312 -0
- package/src/v2-events.ts +73 -0
- package/src/v2-rpc.ts +24 -0
- package/src/v2-session.ts +56 -0
- package/src/v2-setup.ts +362 -0
- package/src/v2-tools.ts +349 -0
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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
|
-
"@
|
|
48
|
+
"@opentui/core": "0.5.10",
|
|
49
|
+
"@opentui/solid": "0.5.10",
|
|
50
|
+
"@types/bun": "latest",
|
|
51
|
+
"solid-js": "1.9.4"
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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"
|
|
@@ -656,10 +658,11 @@ const plugin: Plugin = async (input) => {
|
|
|
656
658
|
}),
|
|
657
659
|
|
|
658
660
|
team_view: tool({
|
|
659
|
-
description: "
|
|
660
|
-
"
|
|
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).",
|
|
661
663
|
args: {
|
|
662
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."),
|
|
663
666
|
},
|
|
664
667
|
async execute(args, ctx) {
|
|
665
668
|
const result = await executeTeamView(deps, args, ctx.sessionID)
|
|
@@ -671,4 +674,24 @@ const plugin: Plugin = async (input) => {
|
|
|
671
674
|
}
|
|
672
675
|
}
|
|
673
676
|
|
|
674
|
-
|
|
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
|
+
}
|
package/src/notify.ts
CHANGED
|
@@ -116,10 +116,12 @@ export function notifyLead(
|
|
|
116
116
|
if (!team?.lead_session_id) return id
|
|
117
117
|
|
|
118
118
|
// Fire-and-forget wake — the system prompt transform delivers the actual
|
|
119
|
-
// content on the lead's next turn.
|
|
119
|
+
// content on the lead's next turn. Synthetic: renders as a persistent
|
|
120
|
+
// system entry, not a fake user turn.
|
|
120
121
|
client.session.promptAsync({
|
|
121
122
|
sessionID: team.lead_session_id,
|
|
122
123
|
parts: [{ type: "text", text: "[System: New team message from system]" }],
|
|
124
|
+
synthetic: true,
|
|
123
125
|
}).catch((err) => {
|
|
124
126
|
log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
125
127
|
})
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RPC contract between the server plugin and terminal companions.
|
|
5
|
+
* Published as `./rpc` so companions import the shape without the
|
|
6
|
+
* implementation. Companion behavior (toasts, navigation) is enhancement
|
|
7
|
+
* only — every client works fully without it.
|
|
8
|
+
*/
|
|
9
|
+
export const EnsembleRpc = Rpc.define({
|
|
10
|
+
id: "ensemble",
|
|
11
|
+
methods: {
|
|
12
|
+
summary: {
|
|
13
|
+
input: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { team: { type: "string" } },
|
|
16
|
+
required: ["team"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: { text: { type: "string" } },
|
|
22
|
+
required: ["text"],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
teamContext: {
|
|
27
|
+
input: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: { sessionID: { type: "string" } },
|
|
30
|
+
required: ["sessionID"],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
output: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
team: { type: ["string", "null"] },
|
|
37
|
+
members: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: { name: { type: "string" }, status: { type: "string" } },
|
|
42
|
+
required: ["name", "status"],
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
tasks: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
pending: { type: "number" },
|
|
50
|
+
done: { type: "number" },
|
|
51
|
+
},
|
|
52
|
+
required: ["pending", "done"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ["team", "members", "tasks"],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
events: {
|
|
62
|
+
member: {
|
|
63
|
+
schema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
memberName: { type: "string" },
|
|
67
|
+
teamId: { type: "string" },
|
|
68
|
+
from: { type: "string" },
|
|
69
|
+
to: { type: "string" },
|
|
70
|
+
},
|
|
71
|
+
required: ["memberName", "teamId", "from", "to"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
notice: {
|
|
76
|
+
schema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
title: { type: "string" },
|
|
80
|
+
message: { type: "string" },
|
|
81
|
+
variant: { type: "string" },
|
|
82
|
+
},
|
|
83
|
+
required: ["message"],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
view: {
|
|
88
|
+
schema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
sessionID: { type: "string" },
|
|
92
|
+
memberName: { type: "string" },
|
|
93
|
+
},
|
|
94
|
+
required: ["sessionID"],
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weak-model tool-argument fumble detection and error hints (issue #39).
|
|
3
|
+
*
|
|
4
|
+
* Some models trained on other harness conventions call OpenCode's built-in
|
|
5
|
+
* tools with aliased parameter names (e.g. `cmd` instead of the `shell`
|
|
6
|
+
* tool's `command`). OpenCode V2 rejects the call with a generic validation
|
|
7
|
+
* error that the model may fail to act on. This module detects that exact
|
|
8
|
+
* dead-call shape and produces an explicit hint appended to the error so
|
|
9
|
+
* the model can self-correct. Arguments are never rewritten — the model
|
|
10
|
+
* stays in the correction loop.
|
|
11
|
+
*
|
|
12
|
+
* V2 only: on V1, argument validation happens at the model-stream parse
|
|
13
|
+
* level, so tool hooks never see the failed call (see issue #39 triage).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonical parameter plus the aliases weak models emit for it. Only the
|
|
18
|
+
* confirmed failure mode is listed; extend per-tool as reports come in.
|
|
19
|
+
*/
|
|
20
|
+
const ARG_ALIASES: Readonly<Record<string, { param: string; aliases: readonly string[] }>> = {
|
|
21
|
+
shell: { param: "command", aliases: ["cmd", "bash", "script"] },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return the alias a weak model used instead of the tool's canonical
|
|
26
|
+
* parameter, or null when the input is not a recognized fumble. A fumble is
|
|
27
|
+
* only recognized when the canonical parameter is absent and a known alias
|
|
28
|
+
* is present as a non-empty string — i.e. only on calls that are already
|
|
29
|
+
* guaranteed to fail validation.
|
|
30
|
+
*/
|
|
31
|
+
export function detectArgFumble(tool: string, input: unknown): string | null {
|
|
32
|
+
const entry = ARG_ALIASES[tool]
|
|
33
|
+
if (!entry) return null
|
|
34
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return null
|
|
35
|
+
const args = input as Record<string, unknown>
|
|
36
|
+
if (entry.param in args) return null
|
|
37
|
+
for (const alias of entry.aliases) {
|
|
38
|
+
const value = args[alias]
|
|
39
|
+
if (typeof value === "string" && value !== "") return alias
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the hint text for a fumbled tool call, or null when the input is
|
|
46
|
+
* not a recognized fumble.
|
|
47
|
+
*/
|
|
48
|
+
export function buildArgHint(tool: string, input: unknown): string | null {
|
|
49
|
+
const entry = ARG_ALIASES[tool]
|
|
50
|
+
if (!entry) return null
|
|
51
|
+
const alias = detectArgFumble(tool, input)
|
|
52
|
+
if (!alias) return null
|
|
53
|
+
return (
|
|
54
|
+
`Hint: the "${tool}" tool has no "${alias}" parameter. The correct parameter ` +
|
|
55
|
+
`name is "${entry.param}" — call the tool again with the same arguments, ` +
|
|
56
|
+
`moving the "${alias}" value into "${entry.param}".`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append the fumble hint to a failed tool call's error message in place.
|
|
62
|
+
* Returns true when the message was modified. Never throws — a malformed
|
|
63
|
+
* error or input leaves the original message untouched.
|
|
64
|
+
*/
|
|
65
|
+
export function enrichToolError(tool: string, error: { message?: unknown }, input: unknown): boolean {
|
|
66
|
+
if (typeof error.message !== "string") return false
|
|
67
|
+
const hint = buildArgHint(tool, input)
|
|
68
|
+
if (!hint) return false
|
|
69
|
+
error.message = `${error.message}\n\n${hint}`
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
@@ -125,6 +125,7 @@ export async function executeTeamMessage(
|
|
|
125
125
|
deps.client.session.promptAsync({
|
|
126
126
|
sessionID: recipientSessionId,
|
|
127
127
|
parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
|
|
128
|
+
synthetic: true,
|
|
128
129
|
}).catch((err) => {
|
|
129
130
|
log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
|
|
130
131
|
})
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -87,7 +87,11 @@ export async function executeTeamSpawn(
|
|
|
87
87
|
.get(teamInfo.teamId, args.name)
|
|
88
88
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// Prefer the agent's own permission rules (V2) so any agent that denies
|
|
91
|
+
// edits — not only the built-in plan/explore — is treated as read-only and
|
|
92
|
+
// never gets a worktree or a shell allow. Fall back to the built-in names.
|
|
93
|
+
const isReadOnly = (await deps.client.agent?.readOnly(agent).catch(() => undefined))
|
|
94
|
+
?? (agent === "plan" || agent === "explore")
|
|
91
95
|
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
|
|
92
96
|
const usePlanApproval = args.plan_approval === true
|
|
93
97
|
|
|
@@ -147,12 +151,32 @@ export async function executeTeamSpawn(
|
|
|
147
151
|
// Permission rules on session.create are the hard gate (server-enforced).
|
|
148
152
|
// For read-only agents, deny write tools and explicitly allow team tools.
|
|
149
153
|
// For all agents with worktrees, allowlist the worktree path for edit/bash.
|
|
150
|
-
|
|
154
|
+
// The 9 member-accessible tools per the documented tool table: the 6 worker
|
|
155
|
+
// tools plus the 3 inspection tools (team_results/team_status/team_view —
|
|
156
|
+
// any member). Caught live 2026-09-15: teammates could not read each
|
|
157
|
+
// other's messages because team_results was missing from this list.
|
|
158
|
+
const TEAM_TOOLS = [
|
|
159
|
+
"team_message",
|
|
160
|
+
"team_broadcast",
|
|
161
|
+
"team_tasks_list",
|
|
162
|
+
"team_tasks_add",
|
|
163
|
+
"team_tasks_complete",
|
|
164
|
+
"team_claim",
|
|
165
|
+
"team_results",
|
|
166
|
+
"team_status",
|
|
167
|
+
"team_view",
|
|
168
|
+
] as const
|
|
151
169
|
const permission: PermissionRule[] = []
|
|
152
170
|
|
|
153
171
|
if (worktreeDir) {
|
|
154
172
|
permission.push(
|
|
155
173
|
{ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
|
|
174
|
+
// V2 checks files inside the session's location as paths relative to
|
|
175
|
+
// it. session.create above binds the location to the worktree (via
|
|
176
|
+
// workspaceID, or directory as fallback), so this rule scopes edits
|
|
177
|
+
// to the worktree. Files outside the worktree remain gated by
|
|
178
|
+
// external_directory.
|
|
179
|
+
{ permission: "edit", pattern: "*", action: "allow" },
|
|
156
180
|
)
|
|
157
181
|
if (!isReadOnly) {
|
|
158
182
|
permission.push({ permission: "bash", pattern: "*", action: "allow" })
|
|
@@ -170,6 +194,12 @@ export async function executeTeamSpawn(
|
|
|
170
194
|
...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
|
|
171
195
|
)
|
|
172
196
|
|
|
197
|
+
// Code Mode is the invocation path for plugin tools on V2 — without this,
|
|
198
|
+
// read-only agents (deny-all policies like explore's) cannot reach any
|
|
199
|
+
// team tool. Safe: nested calls inside execute still enforce their own
|
|
200
|
+
// permissions, so explore stays read-only.
|
|
201
|
+
permission.push({ permission: "execute", pattern: "*", action: "allow" })
|
|
202
|
+
|
|
173
203
|
// Create child session — bind to workspace if available (server-enforced CWD isolation).
|
|
174
204
|
// Falls back to no workspace binding if workspace.create failed.
|
|
175
205
|
let childSessionId: string | undefined
|
|
@@ -180,7 +210,14 @@ export async function executeTeamSpawn(
|
|
|
180
210
|
parentID: sessionId,
|
|
181
211
|
title: `${args.name} (@${agent} teammate)`,
|
|
182
212
|
permission,
|
|
183
|
-
...(workspaceId
|
|
213
|
+
...(workspaceId
|
|
214
|
+
? { workspaceID: workspaceId }
|
|
215
|
+
// Workspace binding is preferred; when it is unavailable, bind the
|
|
216
|
+
// session to the worktree directory directly. Without a location,
|
|
217
|
+
// the teammate's location is the project root, edit paths are
|
|
218
|
+
// evaluated relative to it, and the `edit * allow` rule below
|
|
219
|
+
// would grant edits to the whole shared tree (PR #46 review).
|
|
220
|
+
: worktreeDir ? { directory: worktreeDir } : {}),
|
|
184
221
|
}),
|
|
185
222
|
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
186
223
|
)
|
|
@@ -425,6 +462,13 @@ export async function executeTeamSpawn(
|
|
|
425
462
|
})
|
|
426
463
|
|
|
427
464
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
465
|
+
// Isolation is a claim the lead relies on, so say plainly when it did not
|
|
466
|
+
// complete. The toast alone left "spawned" as the only signal (issue #40).
|
|
467
|
+
// Covers both failure modes: no worktree at all (shared directory) or a
|
|
468
|
+
// worktree whose branch could not be created (unmergeable detached HEAD).
|
|
469
|
+
const isolationInfo = useWorktree && !worktreeBranch
|
|
470
|
+
? ` (WARNING: worktree isolation did not complete — ${args.name} may share your working directory or lack a mergeable branch; edits can collide and team_merge may fail)`
|
|
471
|
+
: ""
|
|
428
472
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
429
473
|
const claimInfo = claimedTaskContent
|
|
430
474
|
? ` (claimed task: ${args.claim_task})`
|
|
@@ -432,5 +476,5 @@ export async function executeTeamSpawn(
|
|
|
432
476
|
// Reset circuit breaker on success
|
|
433
477
|
spawnFailures.delete(teamInfo.teamId)
|
|
434
478
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
435
|
-
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
479
|
+
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${isolationInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
436
480
|
}
|
|
@@ -19,6 +19,23 @@ export async function executeTeamTasksAdd(
|
|
|
19
19
|
): Promise<string> {
|
|
20
20
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
21
21
|
|
|
22
|
+
// Reject unknown dependency IDs before inserting anything: a task blocked on
|
|
23
|
+
// an ID that never existed can never unblock (issue #40). IDs are generated
|
|
24
|
+
// here, so new tasks can only depend on existing ones and no cycle can form.
|
|
25
|
+
const unknown = new Set<string>()
|
|
26
|
+
for (const task of args.tasks) {
|
|
27
|
+
for (const depId of task.depends_on ?? []) {
|
|
28
|
+
const exists = deps.db.query("SELECT 1 FROM team_task WHERE id = ? AND team_id = ?").get(depId, teamInfo.teamId)
|
|
29
|
+
if (!exists) unknown.add(depId)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (unknown.size > 0) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Unknown task ID${unknown.size > 1 ? "s" : ""} in depends_on: ${[...unknown].join(", ")}. ` +
|
|
35
|
+
"Use IDs from team_tasks_list, or add the prerequisite tasks first and reference the IDs they return.",
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
const ids: string[] = []
|
|
23
40
|
const now = Date.now()
|
|
24
41
|
|
package/src/tools/team-view.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { ToolDeps } from "../types"
|
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Execute the team_view tool.
|
|
6
|
-
*
|
|
5
|
+
* Execute the team_view tool. Resolves a teammate's session; navigates the
|
|
6
|
+
* TUI to it ONLY when navigate: true is passed — an agent must never hijack
|
|
7
|
+
* the user's client unprompted. Default behavior reports where the session
|
|
8
|
+
* is so the user can open it themselves (ctrl+p).
|
|
7
9
|
*/
|
|
8
10
|
export async function executeTeamView(
|
|
9
11
|
deps: ToolDeps,
|
|
10
|
-
args: { member: string },
|
|
12
|
+
args: { member: string; navigate?: boolean },
|
|
11
13
|
sessionId: string,
|
|
12
14
|
): Promise<string> {
|
|
13
15
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
@@ -16,9 +18,13 @@ export async function executeTeamView(
|
|
|
16
18
|
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
|
|
17
19
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
18
20
|
|
|
21
|
+
if (args.navigate !== true) {
|
|
22
|
+
return `${args.member}'s session: ${member.session_id} (status: ${member.status}, agent: ${member.agent}). Open it from the session picker (ctrl+p) — or ask to view it and I'll switch for you.`
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
try {
|
|
20
26
|
await deps.client.tui.selectSession({ sessionID: member.session_id })
|
|
21
|
-
return `Switched to ${args.member}'s session.`
|
|
27
|
+
return `Switched to ${args.member}'s session. Use the session picker (ctrl+p) to return.`
|
|
22
28
|
} catch {
|
|
23
29
|
return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`
|
|
24
30
|
}
|
package/src/tui-view.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Minimal member shape for sidebar rendering. */
|
|
2
|
+
export interface SidebarMember {
|
|
3
|
+
name: string
|
|
4
|
+
status: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Rendered sidebar model: counts plus one line per member. */
|
|
8
|
+
export interface SidebarSummary {
|
|
9
|
+
team: string
|
|
10
|
+
working: number
|
|
11
|
+
idle: number
|
|
12
|
+
total: number
|
|
13
|
+
lines: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Glyph per member status (paired with text elsewhere — never color-only). */
|
|
17
|
+
function glyph(status: string): string {
|
|
18
|
+
switch (status) {
|
|
19
|
+
case "busy":
|
|
20
|
+
return "●"
|
|
21
|
+
case "ready":
|
|
22
|
+
return "○"
|
|
23
|
+
case "shutdown_requested":
|
|
24
|
+
return "◐"
|
|
25
|
+
case "shutdown":
|
|
26
|
+
return "■"
|
|
27
|
+
case "error":
|
|
28
|
+
return "✕"
|
|
29
|
+
default:
|
|
30
|
+
return "?"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the sidebar model for a team. Busy members count as working, ready
|
|
36
|
+
* as idle, terminal states (shutdown/error) as done.
|
|
37
|
+
*/
|
|
38
|
+
export function summarizeMembers(team: string, members: SidebarMember[]): SidebarSummary {
|
|
39
|
+
if (members.length === 0) {
|
|
40
|
+
return { team, working: 0, idle: 0, total: 0, lines: ["No teammates"] }
|
|
41
|
+
}
|
|
42
|
+
let working = 0
|
|
43
|
+
let idle = 0
|
|
44
|
+
const lines = members.map((member) => {
|
|
45
|
+
if (member.status === "busy") working += 1
|
|
46
|
+
else if (member.status === "ready") idle += 1
|
|
47
|
+
return `${glyph(member.status)} ${member.name} (${member.status})`
|
|
48
|
+
})
|
|
49
|
+
return { team, working, idle, total: members.length, lines }
|
|
50
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import "@opentui/solid"
|
|
3
|
+
import { createSignal, onCleanup, onMount, Show } from "solid-js"
|
|
4
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
5
|
+
import { EnsembleRpc } from "./rpc"
|
|
6
|
+
|
|
7
|
+
/** Team context shape from the server bridge. */
|
|
8
|
+
interface TeamContextData {
|
|
9
|
+
team: string | null
|
|
10
|
+
members: Array<{ name: string; status: string }>
|
|
11
|
+
tasks: { pending: number; done: number }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Sidebar widget: team members with status plus task counts. */
|
|
15
|
+
function TeamSidebar(props: { fetchContext: () => Promise<TeamContextData | null>; onEvent: (cb: () => void) => () => void }) {
|
|
16
|
+
const [state, setState] = createSignal<TeamContextData | null>(null)
|
|
17
|
+
const load = async (): Promise<void> => {
|
|
18
|
+
try {
|
|
19
|
+
setState(await props.fetchContext())
|
|
20
|
+
} catch {
|
|
21
|
+
// Server unreachable — keep the last known state.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
onMount(() => {
|
|
25
|
+
void load()
|
|
26
|
+
const off = props.onEvent(() => void load())
|
|
27
|
+
onCleanup(off)
|
|
28
|
+
// Heartbeat: events alone go stale (cleanup emits nothing, events can be
|
|
29
|
+
// missed across reconnects). Re-fetch on a slow tick — local RPC read.
|
|
30
|
+
const tick = setInterval(() => void load(), 10_000)
|
|
31
|
+
onCleanup(() => clearInterval(tick))
|
|
32
|
+
})
|
|
33
|
+
return (
|
|
34
|
+
<Show when={state()?.team} fallback={<text>Ensemble: no active team</text>}>
|
|
35
|
+
<box flexDirection="column">
|
|
36
|
+
<text>Ensemble: {state()?.team}</text>
|
|
37
|
+
{(state()?.members ?? []).map((member) => (
|
|
38
|
+
<text>
|
|
39
|
+
{member.status === "busy" ? "●" : member.status === "ready" ? "○" : "■"} {member.name} ({member.status})
|
|
40
|
+
</text>
|
|
41
|
+
))}
|
|
42
|
+
<text>
|
|
43
|
+
tasks: {(state()?.tasks.pending ?? 0)} open / {(state()?.tasks.done ?? 0)} done
|
|
44
|
+
</text>
|
|
45
|
+
</box>
|
|
46
|
+
</Show>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Terminal companion for opencode-ensemble (issue #36).
|
|
52
|
+
*
|
|
53
|
+
* Two persistent surfaces only — transient toasts were removed as noise
|
|
54
|
+
* (live state lives in the sidebar; events live in the transcript as
|
|
55
|
+
* synthetic system lines):
|
|
56
|
+
* 1. Sidebar widget: live member status + task counts.
|
|
57
|
+
* 2. Native agent switching: team_view with navigate: true (user opt-in
|
|
58
|
+
* only — agents never hijack the client unprompted).
|
|
59
|
+
*
|
|
60
|
+
* Enhancement only: desktop, web, and companion-less terminals lose nothing
|
|
61
|
+
* functional — tools, approvals, model-context messages, and the dashboard
|
|
62
|
+
* all live server-side.
|
|
63
|
+
*/
|
|
64
|
+
export default Plugin.define({
|
|
65
|
+
id: "ensemble.tui",
|
|
66
|
+
setup(context) {
|
|
67
|
+
const rpc = context.client.rpc(EnsembleRpc)
|
|
68
|
+
|
|
69
|
+
// Native agent switching: team_view(navigate: true) emits "view";
|
|
70
|
+
// the companion navigates the TUI to the teammate session (ctrl+p to return).
|
|
71
|
+
const offView = rpc.events.on("view", (event) => {
|
|
72
|
+
const data = event.data as unknown as { sessionID: string }
|
|
73
|
+
if (!data.sessionID) return
|
|
74
|
+
if (context.ui.tabs.enabled()) {
|
|
75
|
+
void context.ui.tabs.focus(data.sessionID)
|
|
76
|
+
} else {
|
|
77
|
+
void context.ui.router.navigate({ type: "session", sessionID: data.sessionID })
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const onEvent = (cb: () => void): (() => void) => rpc.events.on("member", cb)
|
|
82
|
+
|
|
83
|
+
const offSidebar = context.ui.slot({
|
|
84
|
+
append: "sidebar.content",
|
|
85
|
+
render: (panel: { sessionID?: string }) => (
|
|
86
|
+
<TeamSidebar
|
|
87
|
+
fetchContext={async () => {
|
|
88
|
+
if (!panel.sessionID) return null
|
|
89
|
+
return (await rpc.teamContext({ sessionID: panel.sessionID })) as TeamContextData
|
|
90
|
+
}}
|
|
91
|
+
onEvent={onEvent}
|
|
92
|
+
/>
|
|
93
|
+
),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
offView()
|
|
98
|
+
offSidebar()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface PermissionRule {
|
|
|
35
35
|
* Makes mocking trivial in tests.
|
|
36
36
|
*/
|
|
37
37
|
export interface PluginClient {
|
|
38
|
+
/** Optional agent introspection (V2). Undefined answers fall back to name checks. */
|
|
39
|
+
agent?: {
|
|
40
|
+
readOnly(agent: string): Promise<boolean | undefined>
|
|
41
|
+
}
|
|
38
42
|
session: {
|
|
39
43
|
create(options: {
|
|
40
44
|
parentID?: string
|
|
@@ -50,6 +54,8 @@ export interface PluginClient {
|
|
|
50
54
|
agent?: string
|
|
51
55
|
tools?: Record<string, boolean>
|
|
52
56
|
system?: string
|
|
57
|
+
/** V2: deliver as a synthetic (system) message instead of a user prompt. */
|
|
58
|
+
synthetic?: boolean
|
|
53
59
|
}): Promise<unknown>
|
|
54
60
|
abort(options: { sessionID: string }): Promise<unknown>
|
|
55
61
|
status(): Promise<{ data?: Record<string, { type: string }> }>
|