@hueyexe/opencode-ensemble 0.18.0 → 0.21.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 +45 -24
- package/dist/index.js +17224 -30767
- package/package.json +2 -2
- package/src/config.ts +33 -0
- package/src/dashboard-html.ts +34 -1
- package/src/dashboard-js-events.ts +9 -0
- package/src/dashboard-js-graph-layout.ts +40 -0
- package/src/dashboard-js-graph.ts +99 -0
- package/src/dashboard.ts +8 -19
- package/src/hooks.ts +3 -3
- package/src/index.ts +11 -23
- package/src/member-model.ts +107 -3
- package/src/messaging.ts +4 -2
- package/src/notify.ts +9 -18
- package/src/recovery.ts +23 -16
- package/src/schema.ts +4 -0
- package/src/system-prompt.ts +79 -50
- package/src/task-graph.ts +116 -0
- package/src/tool-arg-hint.ts +71 -0
- package/src/tools/team-broadcast.ts +21 -3
- package/src/tools/team-message.ts +4 -5
- package/src/tools/team-results.ts +15 -5
- package/src/tools/team-spawn.ts +68 -16
- package/src/tools/team-tasks-add.ts +56 -3
- package/src/tools/team-tasks-complete.ts +2 -1
- package/src/types.ts +5 -1
- package/src/v2-client.ts +103 -14
- package/src/v2-setup.ts +16 -3
- package/src/wake.ts +198 -0
package/src/tools/team-spawn.ts
CHANGED
|
@@ -4,23 +4,25 @@ import { requireLead } from "./shared"
|
|
|
4
4
|
import { claimTask } from "./team-claim"
|
|
5
5
|
import { notifyLead } from "../notify"
|
|
6
6
|
import { releaseMemberTasks } from "../tasks"
|
|
7
|
-
import { parseModelId } from "../member-model"
|
|
7
|
+
import { parseModelId, formatModelId, getSessionSelection } from "../member-model"
|
|
8
8
|
import { log } from "../log"
|
|
9
9
|
import type { EnsembleConfig } from "../config"
|
|
10
|
-
import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
10
|
+
import { getTeamResourceParts, teamWorktreeName, preserveBranch, preservedBranchName } from "./merge-helper"
|
|
11
11
|
|
|
12
12
|
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
13
13
|
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Resolve which model to use for a spawned agent.
|
|
17
|
-
* Priority: explicit arg > modelsByAgent > rotation/random > defaultModel >
|
|
17
|
+
* Priority: explicit arg > modelsByAgent > rotation/random > defaultModel >
|
|
18
|
+
* the lead conversation's current model (fallbackModel) > undefined.
|
|
18
19
|
*/
|
|
19
20
|
export function resolveModel(
|
|
20
21
|
explicitModel: string | undefined,
|
|
21
22
|
agentType: string,
|
|
22
23
|
teamMemberCount: number,
|
|
23
24
|
config: Required<EnsembleConfig>,
|
|
25
|
+
fallbackModel?: string,
|
|
24
26
|
): string | undefined {
|
|
25
27
|
if (explicitModel) return explicitModel
|
|
26
28
|
if (config.modelsByAgent[agentType]) return config.modelsByAgent[agentType]
|
|
@@ -31,7 +33,7 @@ export function resolveModel(
|
|
|
31
33
|
return config.modelPool[Math.floor(Math.random() * config.modelPool.length)]
|
|
32
34
|
}
|
|
33
35
|
if (config.defaultModel) return config.defaultModel
|
|
34
|
-
return
|
|
36
|
+
return fallbackModel
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
|
|
@@ -87,7 +89,11 @@ export async function executeTeamSpawn(
|
|
|
87
89
|
.get(teamInfo.teamId, args.name)
|
|
88
90
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
// Prefer the agent's own permission rules (V2) so any agent that denies
|
|
93
|
+
// edits — not only the built-in plan/explore — is treated as read-only and
|
|
94
|
+
// never gets a worktree or a shell allow. Fall back to the built-in names.
|
|
95
|
+
const isReadOnly = (await deps.client.agent?.readOnly(agent).catch(() => undefined))
|
|
96
|
+
?? (agent === "plan" || agent === "explore")
|
|
91
97
|
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
|
|
92
98
|
const usePlanApproval = args.plan_approval === true
|
|
93
99
|
|
|
@@ -167,6 +173,12 @@ export async function executeTeamSpawn(
|
|
|
167
173
|
if (worktreeDir) {
|
|
168
174
|
permission.push(
|
|
169
175
|
{ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
|
|
176
|
+
// V2 checks files inside the session's location as paths relative to
|
|
177
|
+
// it. session.create above binds the location to the worktree (via
|
|
178
|
+
// workspaceID, or directory as fallback), so this rule scopes edits
|
|
179
|
+
// to the worktree. Files outside the worktree remain gated by
|
|
180
|
+
// external_directory.
|
|
181
|
+
{ permission: "edit", pattern: "*", action: "allow" },
|
|
170
182
|
)
|
|
171
183
|
if (!isReadOnly) {
|
|
172
184
|
permission.push({ permission: "bash", pattern: "*", action: "allow" })
|
|
@@ -200,7 +212,14 @@ export async function executeTeamSpawn(
|
|
|
200
212
|
parentID: sessionId,
|
|
201
213
|
title: `${args.name} (@${agent} teammate)`,
|
|
202
214
|
permission,
|
|
203
|
-
...(workspaceId
|
|
215
|
+
...(workspaceId
|
|
216
|
+
? { workspaceID: workspaceId }
|
|
217
|
+
// Workspace binding is preferred; when it is unavailable, bind the
|
|
218
|
+
// session to the worktree directory directly. Without a location,
|
|
219
|
+
// the teammate's location is the project root, edit paths are
|
|
220
|
+
// evaluated relative to it, and the `edit * allow` rule below
|
|
221
|
+
// would grant edits to the whole shared tree (PR #46 review).
|
|
222
|
+
: worktreeDir ? { directory: worktreeDir } : {}),
|
|
204
223
|
}),
|
|
205
224
|
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
206
225
|
)
|
|
@@ -237,7 +256,21 @@ export async function executeTeamSpawn(
|
|
|
237
256
|
const now = Date.now()
|
|
238
257
|
// Resolve model before DB insert so the stored value matches what promptAsync uses
|
|
239
258
|
const memberCount = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId) as { c: number }).c
|
|
240
|
-
|
|
259
|
+
let resolvedModel = resolveModel(args.model, agent, memberCount, deps.config)
|
|
260
|
+
if (!resolvedModel) {
|
|
261
|
+
// No explicit/config model: inherit the lead conversation's current model
|
|
262
|
+
// (issue #55). Read at spawn time, bounded so a slow lookup cannot hang
|
|
263
|
+
// the spawn; unknown selection falls through to the host default.
|
|
264
|
+
const leadSelection = await withTimeout(
|
|
265
|
+
getSessionSelection(deps.client, sessionId),
|
|
266
|
+
5000,
|
|
267
|
+
"lead model lookup",
|
|
268
|
+
).catch(() => undefined)
|
|
269
|
+
if (leadSelection?.model) {
|
|
270
|
+
resolvedModel = formatModelId(leadSelection.model)
|
|
271
|
+
log(`spawn:model:inherited name=${args.name} model=${resolvedModel}`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
241
274
|
if (resolvedModel) log(`spawn:model name=${args.name} model=${resolvedModel}`)
|
|
242
275
|
|
|
243
276
|
deps.db.run(
|
|
@@ -408,7 +441,7 @@ export async function executeTeamSpawn(
|
|
|
408
441
|
parts: [{ type: "text", text: contextStr }],
|
|
409
442
|
agent,
|
|
410
443
|
...(modelParam ? { model: modelParam } : {}),
|
|
411
|
-
}).catch((err) => {
|
|
444
|
+
}).catch(async (err) => {
|
|
412
445
|
const errMsg = err instanceof Error ? err.message : String(err)
|
|
413
446
|
log(`spawn:promptAsync:failed name=${args.name} err=${errMsg} — rolling back`)
|
|
414
447
|
try {
|
|
@@ -421,13 +454,6 @@ export async function executeTeamSpawn(
|
|
|
421
454
|
[Date.now(), args.claim_task, args.name]
|
|
422
455
|
)
|
|
423
456
|
}
|
|
424
|
-
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
425
|
-
if (workspaceId) {
|
|
426
|
-
deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
|
|
427
|
-
}
|
|
428
|
-
if (worktreeDir) {
|
|
429
|
-
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
430
|
-
}
|
|
431
457
|
const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : ""
|
|
432
458
|
deps.client.tui.showToast({
|
|
433
459
|
title: "Team",
|
|
@@ -441,10 +467,36 @@ export async function executeTeamSpawn(
|
|
|
441
467
|
teamInfo.teamId,
|
|
442
468
|
`Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
|
|
443
469
|
)
|
|
470
|
+
|
|
471
|
+
// promptAsync can reject after the server accepted the prompt, so the
|
|
472
|
+
// teammate may have run and committed. Preserve the branch before abort:
|
|
473
|
+
// session cleanup asynchronously deletes the worktree branch.
|
|
474
|
+
if (worktreeBranch) {
|
|
475
|
+
try {
|
|
476
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
477
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, args.name)
|
|
478
|
+
const preserved = await preserveBranch(worktreeBranch, safeBranch, deps.directory)
|
|
479
|
+
if (preserved) log(`spawn:rollback:branch:preserved src=${worktreeBranch} target=${safeBranch}`)
|
|
480
|
+
} catch { /* best effort — still abort/clean up */ }
|
|
481
|
+
}
|
|
482
|
+
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
483
|
+
if (workspaceId) {
|
|
484
|
+
deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
|
|
485
|
+
}
|
|
486
|
+
if (worktreeDir) {
|
|
487
|
+
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
488
|
+
}
|
|
444
489
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
445
490
|
})
|
|
446
491
|
|
|
447
492
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
493
|
+
// Isolation is a claim the lead relies on, so say plainly when it did not
|
|
494
|
+
// complete. The toast alone left "spawned" as the only signal (issue #40).
|
|
495
|
+
// Covers both failure modes: no worktree at all (shared directory) or a
|
|
496
|
+
// worktree whose branch could not be created (unmergeable detached HEAD).
|
|
497
|
+
const isolationInfo = useWorktree && !worktreeBranch
|
|
498
|
+
? ` (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)`
|
|
499
|
+
: ""
|
|
448
500
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
449
501
|
const claimInfo = claimedTaskContent
|
|
450
502
|
? ` (claimed task: ${args.claim_task})`
|
|
@@ -452,5 +504,5 @@ export async function executeTeamSpawn(
|
|
|
452
504
|
// Reset circuit breaker on success
|
|
453
505
|
spawnFailures.delete(teamInfo.teamId)
|
|
454
506
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
455
|
-
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
507
|
+
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${isolationInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
456
508
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { generateId } from "../util"
|
|
4
|
+
import { inspectTaskGraph, parseTaskDependencies, depthForDependencies } from "../task-graph"
|
|
4
5
|
|
|
5
6
|
interface TaskInput {
|
|
6
7
|
content: string
|
|
@@ -18,17 +19,69 @@ export async function executeTeamTasksAdd(
|
|
|
18
19
|
sessionId: string,
|
|
19
20
|
): Promise<string> {
|
|
20
21
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
22
|
+
const tasks = args.tasks.map(task => ({
|
|
23
|
+
...task,
|
|
24
|
+
depends_on: [...new Set(task.depends_on ?? [])],
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
// Reject unknown dependency IDs before inserting anything: a task blocked on
|
|
28
|
+
// an ID that never existed can never unblock (issue #40). IDs are generated
|
|
29
|
+
// here, so new tasks can only depend on existing ones and no cycle can form.
|
|
30
|
+
const unknown = new Set<string>()
|
|
31
|
+
for (const task of tasks) {
|
|
32
|
+
for (const depId of task.depends_on) {
|
|
33
|
+
const exists = deps.db.query("SELECT 1 FROM team_task WHERE id = ? AND team_id = ?").get(depId, teamInfo.teamId)
|
|
34
|
+
if (!exists) unknown.add(depId)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (unknown.size > 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Unknown task ID${unknown.size > 1 ? "s" : ""} in depends_on: ${[...unknown].join(", ")}. ` +
|
|
40
|
+
"Use IDs from team_tasks_list, or add the prerequisite tasks first and reference the IDs they return.",
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Optional depth guard (issue #42). Default is unlimited; when configured,
|
|
45
|
+
// reject the whole batch before any insert so a failed add is atomic.
|
|
46
|
+
const depthLimit = deps.config.taskDepthLimit
|
|
47
|
+
if (depthLimit > 0) {
|
|
48
|
+
const existing = deps.db.query("SELECT id, status, depends_on FROM team_task WHERE team_id = ?")
|
|
49
|
+
.all(teamInfo.teamId) as Array<{ id: string; status: string; depends_on: string | null }>
|
|
50
|
+
const graph = inspectTaskGraph(existing.map(row => ({
|
|
51
|
+
id: row.id,
|
|
52
|
+
status: row.status,
|
|
53
|
+
dependsOn: parseTaskDependencies(row.depends_on),
|
|
54
|
+
})), { activeOnly: true })
|
|
55
|
+
|
|
56
|
+
if (graph.cyclic.length > 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Task graph has a dependency cycle (${graph.cyclic.join(", ")}). Repair it before adding more tasks.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const task of tasks) {
|
|
63
|
+
const depth = depthForDependencies(task.depends_on, graph)
|
|
64
|
+
if (depth <= depthLimit) continue
|
|
65
|
+
const resolution = deps.config.taskDepthLimitOverflowAction === "user_resolve"
|
|
66
|
+
? "Ask the user whether to split the work into a shallower chain, then retry."
|
|
67
|
+
: "Split the work into shallower batches, add an independent task, or raise taskDepthLimit."
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Task depth ${depth} exceeds taskDepthLimit ${depthLimit}. ` +
|
|
70
|
+
`The new task depends on ${task.depends_on.join(", ")}. ${resolution}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
21
74
|
|
|
22
75
|
const ids: string[] = []
|
|
23
76
|
const now = Date.now()
|
|
24
77
|
|
|
25
|
-
for (const task of
|
|
78
|
+
for (const task of tasks) {
|
|
26
79
|
const id = generateId("task")
|
|
27
|
-
const depsJson = task.depends_on
|
|
80
|
+
const depsJson = task.depends_on.length ? JSON.stringify(task.depends_on) : null
|
|
28
81
|
|
|
29
82
|
// Determine initial status — blocked if has unresolved dependencies
|
|
30
83
|
let status = "pending"
|
|
31
|
-
if (task.depends_on
|
|
84
|
+
if (task.depends_on.length) {
|
|
32
85
|
const resolved = task.depends_on.every(depId => {
|
|
33
86
|
const dep = deps.db.query("SELECT status FROM team_task WHERE id = ? AND team_id = ?")
|
|
34
87
|
.get(depId, teamInfo.teamId) as { status: string } | null
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
|
+
import { parseTaskDependencies } from "../task-graph"
|
|
3
4
|
import { log } from "../log"
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -51,7 +52,7 @@ export async function executeTeamTasksComplete(
|
|
|
51
52
|
let unblocked = 0
|
|
52
53
|
for (const t of allTasks) {
|
|
53
54
|
if (t.status !== "blocked" || !t.depends_on) continue
|
|
54
|
-
const depIds
|
|
55
|
+
const depIds = parseTaskDependencies(t.depends_on)
|
|
55
56
|
if (!depIds.includes(args.task_id)) continue
|
|
56
57
|
|
|
57
58
|
const allResolved = depIds.every(depId => {
|
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
|
|
@@ -46,7 +50,7 @@ export interface PluginClient {
|
|
|
46
50
|
promptAsync(options: {
|
|
47
51
|
sessionID: string
|
|
48
52
|
parts: Array<{ type: "text"; text: string }>
|
|
49
|
-
model?: { providerID: string; modelID: string }
|
|
53
|
+
model?: { providerID: string; modelID: string; variant?: string }
|
|
50
54
|
agent?: string
|
|
51
55
|
tools?: Record<string, boolean>
|
|
52
56
|
system?: string
|
package/src/v2-client.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { PluginClient } from "./types"
|
|
2
|
+
import { log } from "./log"
|
|
2
3
|
import { runCommand } from "./process"
|
|
3
4
|
|
|
4
5
|
/** V2 plugin context subset Ensemble needs (structural, mock-friendly). */
|
|
5
6
|
export interface V2Context {
|
|
7
|
+
/** Plugin location; `project.id` is required by the 2.0.19+ worktree API. */
|
|
8
|
+
location?: { directory?: string; project?: { id?: string } }
|
|
6
9
|
session: {
|
|
7
10
|
create(input: Record<string, unknown>): Promise<{ id: string }>
|
|
8
11
|
prompt(input: Record<string, unknown>): Promise<unknown>
|
|
@@ -17,12 +20,39 @@ export interface V2Context {
|
|
|
17
20
|
worktree: {
|
|
18
21
|
create(input: Record<string, unknown>): Promise<{ directory: string }>
|
|
19
22
|
remove(input: Record<string, unknown>): Promise<unknown>
|
|
20
|
-
list(): Promise<Array<{ directory: string }>>
|
|
21
|
-
refresh(): Promise<unknown>
|
|
23
|
+
list(input?: Record<string, unknown>): Promise<Array<{ directory: string }>>
|
|
24
|
+
refresh(input?: Record<string, unknown>): Promise<unknown>
|
|
22
25
|
}
|
|
23
26
|
permission: {
|
|
24
|
-
|
|
27
|
+
/** Present on 2.0.3–2.0.18 only; removed in 2.0.19 (see issue #40). */
|
|
28
|
+
rules?(input: Record<string, unknown>): Promise<unknown>
|
|
25
29
|
}
|
|
30
|
+
/** Agent lookup (2.0.19+): exposes each agent's resolved permission rules. */
|
|
31
|
+
agent?: {
|
|
32
|
+
get(input: Record<string, unknown>): Promise<unknown>
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type PermissionRuleV2 = { action?: unknown; resource?: unknown; effect?: unknown }
|
|
37
|
+
|
|
38
|
+
/** OpenCode wildcard match: `*` matches any run of characters. */
|
|
39
|
+
function wildcardMatch(value: string, pattern: string): boolean {
|
|
40
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
|
|
41
|
+
return new RegExp(`^${escaped}$`).test(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when an agent's own rules deny editing everywhere. OpenCode evaluates
|
|
46
|
+
* the last matching rule, so this mirrors `evaluate("edit", "*")` over the
|
|
47
|
+
* agent ruleset. Undefined when the rules carry no edit decision.
|
|
48
|
+
*/
|
|
49
|
+
export function agentRulesAreReadOnly(rules: unknown): boolean | undefined {
|
|
50
|
+
if (!Array.isArray(rules)) return undefined
|
|
51
|
+
const match = [...(rules as PermissionRuleV2[])].reverse().find((rule) =>
|
|
52
|
+
typeof rule?.action === "string" && typeof rule?.resource === "string"
|
|
53
|
+
&& wildcardMatch("edit", rule.action) && (rule.resource === "*" || rule.resource === "**"))
|
|
54
|
+
if (!match) return undefined
|
|
55
|
+
return match.effect === "deny"
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
/** Prefix marking a workspace id that is really a local directory (see below). */
|
|
@@ -32,6 +62,10 @@ const DIR_WORKSPACE_PREFIX = "v2dir:"
|
|
|
32
62
|
export interface V2ClientDeps {
|
|
33
63
|
/** Current branch checked out in a directory, or null when unknown. */
|
|
34
64
|
gitBranch?: (directory: string) => Promise<string | null>
|
|
65
|
+
/** Create and check out a branch in a directory. Returns the effective
|
|
66
|
+
* branch name, or null when the branch could not be created or checked
|
|
67
|
+
* out — never record the requested name without this confirmation. */
|
|
68
|
+
gitCreateBranch?: (directory: string, branch: string) => Promise<string | null>
|
|
35
69
|
/** Worktree directory currently on a branch, or null when none. */
|
|
36
70
|
gitDir?: (branch: string) => Promise<string | null>
|
|
37
71
|
/** RPC emitter for companion events (team_view navigation). */
|
|
@@ -49,6 +83,31 @@ async function gitBranchDefault(directory: string): Promise<string | null> {
|
|
|
49
83
|
}
|
|
50
84
|
}
|
|
51
85
|
|
|
86
|
+
/** Create and check out a branch in a directory via git. Returns the
|
|
87
|
+
* effective branch name, or null when neither create nor switch worked —
|
|
88
|
+
* callers must record the return value, not the requested name, so a
|
|
89
|
+
* failed create can never be recorded as a live branch. */
|
|
90
|
+
async function gitCreateBranchDefault(directory: string, branch: string): Promise<string | null> {
|
|
91
|
+
try {
|
|
92
|
+
await runCommand(["git", "switch", "-c", branch], { cwd: directory })
|
|
93
|
+
return branch
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// The branch may already exist (re-spawn after the abort-cleanup race
|
|
96
|
+
// documented in AGENTS.md) — switch to it instead of recording a name
|
|
97
|
+
// with no ref behind it. Log both attempts; swallowing failures silently
|
|
98
|
+
// made team_merge squash a stale branch and silently lose new work
|
|
99
|
+
// (PR #46 review).
|
|
100
|
+
log(`v2-client:gitCreateBranch:create-failed directory=${directory} branch=${branch} err=${err instanceof Error ? err.message : String(err)} — trying existing branch`)
|
|
101
|
+
try {
|
|
102
|
+
await runCommand(["git", "switch", branch], { cwd: directory })
|
|
103
|
+
return branch
|
|
104
|
+
} catch (err2) {
|
|
105
|
+
log(`v2-client:gitCreateBranch:switch-failed directory=${directory} branch=${branch} err=${err2 instanceof Error ? err2.message : String(err2)}`)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
52
111
|
/** Concatenate the text parts of a promptAsync payload into one string. */
|
|
53
112
|
function joinParts(parts: Array<{ type: "text"; text: string }>): string {
|
|
54
113
|
return parts
|
|
@@ -88,7 +147,24 @@ function joinParts(parts: Array<{ type: "text"; text: string }>): string {
|
|
|
88
147
|
*/
|
|
89
148
|
export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient {
|
|
90
149
|
const gitBranch = deps.gitBranch ?? gitBranchDefault
|
|
150
|
+
const gitCreateBranch = deps.gitCreateBranch ?? gitCreateBranchDefault
|
|
151
|
+
// 2.0.19+ worktree endpoints require the project; older hosts ignore it.
|
|
152
|
+
const project = (): Record<string, unknown> => {
|
|
153
|
+
const projectID = ctx.location?.project?.id
|
|
154
|
+
return projectID ? { projectID } : {}
|
|
155
|
+
}
|
|
91
156
|
return {
|
|
157
|
+
agent: {
|
|
158
|
+
readOnly: async (agent: string) => {
|
|
159
|
+
if (!ctx.agent) return undefined
|
|
160
|
+
try {
|
|
161
|
+
const info = (await ctx.agent.get({ agentID: agent })) as { permissions?: unknown; data?: { permissions?: unknown } } | undefined
|
|
162
|
+
return agentRulesAreReadOnly(info?.permissions ?? info?.data?.permissions)
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
92
168
|
session: {
|
|
93
169
|
create: async (options) => {
|
|
94
170
|
const input: Record<string, unknown> = { title: options.title }
|
|
@@ -114,11 +190,12 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
114
190
|
}
|
|
115
191
|
}
|
|
116
192
|
const created = await ctx.session.create(input)
|
|
117
|
-
if (permissions) {
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// agent
|
|
193
|
+
if (permissions && typeof ctx.permission?.rules === "function") {
|
|
194
|
+
// 2.0.3–2.0.18 merged agent rules after session rules, so create()
|
|
195
|
+
// permissions lost to the agent's and had to be re-asserted. From
|
|
196
|
+
// 2.0.19 the session ruleset merges last (core permission.ts
|
|
197
|
+
// `merge(agent.permissions, session.permissions)` with last-match
|
|
198
|
+
// evaluation) and rules() no longer exists, so create() is enough.
|
|
122
199
|
await ctx.permission.rules({ sessionID: created.id, permissions })
|
|
123
200
|
}
|
|
124
201
|
return { data: { id: created.id } }
|
|
@@ -138,7 +215,11 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
138
215
|
if (options.model) {
|
|
139
216
|
await ctx.session.switchModel({
|
|
140
217
|
sessionID: options.sessionID,
|
|
141
|
-
model: {
|
|
218
|
+
model: {
|
|
219
|
+
providerID: options.model.providerID,
|
|
220
|
+
id: options.model.modelID,
|
|
221
|
+
...(options.model.variant ? { variant: options.model.variant } : {}),
|
|
222
|
+
},
|
|
142
223
|
})
|
|
143
224
|
}
|
|
144
225
|
const text = joinParts(options.parts)
|
|
@@ -177,16 +258,24 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
177
258
|
worktree: {
|
|
178
259
|
create: async (options) => {
|
|
179
260
|
const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree"
|
|
180
|
-
const created = await ctx.worktree.create({ name })
|
|
181
|
-
|
|
261
|
+
const created = await ctx.worktree.create({ ...project(), name })
|
|
262
|
+
let branch = await gitBranch(created.directory)
|
|
263
|
+
if (!branch) {
|
|
264
|
+
// 2.0.32+ creates worktrees detached; team_merge needs a named
|
|
265
|
+
// branch. Record the effective branch only — never the requested
|
|
266
|
+
// name — so a failed create cannot point team_merge at a stale
|
|
267
|
+
// same-named branch whose commits would silently shadow new work
|
|
268
|
+
// (PR #46 review).
|
|
269
|
+
branch = (await gitCreateBranch(created.directory, name)) ?? ""
|
|
270
|
+
}
|
|
182
271
|
return { data: { name, branch, directory: created.directory } }
|
|
183
272
|
},
|
|
184
273
|
remove: async (options) => {
|
|
185
274
|
const directory = (options.worktreeRemoveInput as { directory: string }).directory
|
|
186
|
-
return ctx.worktree.remove({ directory, force: false })
|
|
275
|
+
return ctx.worktree.remove({ ...project(), directory, force: false })
|
|
187
276
|
},
|
|
188
277
|
list: async () => {
|
|
189
|
-
const entries = await ctx.worktree.list()
|
|
278
|
+
const entries = await ctx.worktree.list(project())
|
|
190
279
|
return {
|
|
191
280
|
data: entries.map((entry) => ({
|
|
192
281
|
name: entry.directory.split("/").pop() ?? entry.directory,
|
|
@@ -195,7 +284,7 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
195
284
|
})),
|
|
196
285
|
}
|
|
197
286
|
},
|
|
198
|
-
reset: async () => ctx.worktree.refresh(),
|
|
287
|
+
reset: async () => ctx.worktree.refresh(project()),
|
|
199
288
|
},
|
|
200
289
|
workspace: {
|
|
201
290
|
create: async (options) => {
|
package/src/v2-setup.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ToolDeps } from "./types"
|
|
|
11
11
|
import { ProgressTracker } from "./progress"
|
|
12
12
|
import { checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
|
|
13
13
|
import { hasReportedCompletion } from "./messaging"
|
|
14
|
+
import { enrichToolError } from "./tool-arg-hint"
|
|
14
15
|
import { rehydrateRegistry } from "./recovery"
|
|
15
16
|
import { findTeamBySession } from "./types"
|
|
16
17
|
import { loadConfig } from "./config"
|
|
@@ -24,7 +25,8 @@ import { vlog } from "./log"
|
|
|
24
25
|
|
|
25
26
|
/** V2 setup context subset (structural, mock-friendly). */
|
|
26
27
|
export interface V2SetupContext {
|
|
27
|
-
location: { directory: string }
|
|
28
|
+
location: { directory: string; project?: { id?: string } }
|
|
29
|
+
agent?: V2Context["agent"]
|
|
28
30
|
options: Record<string, unknown>
|
|
29
31
|
session: V2Context["session"] & {
|
|
30
32
|
hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
|
|
@@ -251,6 +253,17 @@ export async function setupEnsemble(
|
|
|
251
253
|
input: unknown
|
|
252
254
|
status: string
|
|
253
255
|
result?: { content?: string | Array<{ type?: string; text?: string }> }
|
|
256
|
+
error?: { message?: unknown }
|
|
257
|
+
}
|
|
258
|
+
if (event.status === "error" && event.error) {
|
|
259
|
+
// Weak-model arg fumbles (issue #39): the call is already dead from
|
|
260
|
+
// validation, so append an explicit "the param is X, not Y" hint to
|
|
261
|
+
// the error the model sees. Arguments are never rewritten — the
|
|
262
|
+
// model stays in the correction loop. Fail-open by design: a
|
|
263
|
+
// non-fumble error is returned untouched.
|
|
264
|
+
if (enrichToolError(event.tool, event.error, event.input)) {
|
|
265
|
+
vlog(`tool-arg-hint:fired tool=${event.tool} session=${event.sessionID}`)
|
|
266
|
+
}
|
|
254
267
|
}
|
|
255
268
|
if (event.tool === "question") {
|
|
256
269
|
// Feed the answer text to purge approvals (team_cleanup confirm flow).
|
|
@@ -281,7 +294,7 @@ export async function setupEnsemble(
|
|
|
281
294
|
const prompt =
|
|
282
295
|
teamInfo.role === "lead"
|
|
283
296
|
? buildLeadSystemPrompt(db, teamInfo.teamId, config)
|
|
284
|
-
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
297
|
+
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown", config)
|
|
285
298
|
vlog(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
|
|
286
299
|
event.system.push({ type: "text", text: prompt })
|
|
287
300
|
if (teamInfo.role === "member") {
|
|
@@ -330,7 +343,7 @@ export async function setupEnsemble(
|
|
|
330
343
|
let dashboard: DashboardServer | null = null
|
|
331
344
|
const dashboardPort = options.dashboardPort ?? config.dashboardPort
|
|
332
345
|
if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) {
|
|
333
|
-
dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => {
|
|
346
|
+
dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => {
|
|
334
347
|
vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
335
348
|
return null
|
|
336
349
|
})
|