@hueyexe/opencode-ensemble 0.6.4 → 0.7.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 +24 -2
- package/dist/index.js +206 -70
- package/package.json +1 -1
- package/src/client.ts +6 -0
- package/src/index.ts +10 -4
- package/src/schema.ts +2 -0
- package/src/system-prompt.ts +4 -0
- package/src/tools/shared.ts +39 -0
- package/src/tools/team-broadcast.ts +2 -3
- package/src/tools/team-claim.ts +2 -3
- package/src/tools/team-cleanup.ts +41 -11
- package/src/tools/team-message.ts +3 -3
- package/src/tools/team-results.ts +2 -3
- package/src/tools/team-shutdown.ts +23 -8
- package/src/tools/team-spawn.ts +133 -37
- package/src/tools/team-status.ts +7 -5
- package/src/tools/team-tasks-add.ts +2 -3
- package/src/tools/team-tasks-complete.ts +2 -3
- package/src/tools/team-tasks-list.ts +2 -3
- package/src/tools/team-view.ts +2 -3
- package/src/types.ts +5 -0
- package/src/watchdog.ts +31 -2
package/src/system-prompt.ts
CHANGED
|
@@ -75,6 +75,10 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
75
75
|
"Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
|
|
76
76
|
"After spawning all teammates, tell the user what you've set up and wait.",
|
|
77
77
|
"When all teammates finish, summarize results and suggest next steps.",
|
|
78
|
+
"",
|
|
79
|
+
"Before calling team_cleanup, verify teammates have committed their work.",
|
|
80
|
+
"team_shutdown will warn you if a teammate has uncommitted changes.",
|
|
81
|
+
"team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
|
|
78
82
|
)
|
|
79
83
|
|
|
80
84
|
return lines.join("\n")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/** Function type for dirty worktree check — injectable for testing. */
|
|
5
|
+
export type IsDirtyFn = (dir: string) => Promise<boolean>
|
|
6
|
+
|
|
7
|
+
/** Check if a worktree directory has uncommitted changes via git status. */
|
|
8
|
+
export async function checkWorktreeDirty(dir: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" })
|
|
11
|
+
const output = await new Response(proc.stdout).text()
|
|
12
|
+
const exitCode = await proc.exited
|
|
13
|
+
if (exitCode !== 0) return false // git failed — assume clean
|
|
14
|
+
return output.trim().length > 0
|
|
15
|
+
} catch {
|
|
16
|
+
return false // can't check — assume clean
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Validate that the session belongs to the team lead. Throws if not. */
|
|
21
|
+
export function requireLead(
|
|
22
|
+
deps: Pick<ToolDeps, "db" | "registry">,
|
|
23
|
+
sessionId: string,
|
|
24
|
+
): { teamId: string; teamName: string } {
|
|
25
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
26
|
+
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
27
|
+
if (teamInfo.role !== "lead") throw new Error("Only the team lead can use this tool.")
|
|
28
|
+
return { teamId: teamInfo.teamId, teamName: teamInfo.teamName }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validate that the session belongs to any team member (lead or teammate). Throws if not. */
|
|
32
|
+
export function requireTeamMember(
|
|
33
|
+
deps: Pick<ToolDeps, "db" | "registry">,
|
|
34
|
+
sessionId: string,
|
|
35
|
+
): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } {
|
|
36
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
37
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
38
|
+
return teamInfo
|
|
39
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { broadcastMessage, markDelivered } from "../messaging"
|
|
4
4
|
import { log } from "../log"
|
|
5
5
|
|
|
@@ -11,8 +11,7 @@ export async function executeTeamBroadcast(
|
|
|
11
11
|
args: { text: string },
|
|
12
12
|
sessionId: string,
|
|
13
13
|
): Promise<string> {
|
|
14
|
-
const teamInfo =
|
|
15
|
-
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
14
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
16
15
|
|
|
17
16
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
18
17
|
|
package/src/tools/team-claim.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_claim tool. Atomically claims a pending task.
|
|
@@ -10,8 +10,7 @@ export async function executeTeamClaim(
|
|
|
10
10
|
args: { task_id: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
|
-
const teamInfo =
|
|
14
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
15
14
|
|
|
16
15
|
const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
17
16
|
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
3
|
+
import type { IsDirtyFn } from "./shared"
|
|
4
|
+
import { log } from "../log"
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Execute the team_cleanup tool. Archives the team and cleans up resources.
|
|
6
|
-
*
|
|
7
|
-
* If force=true, aborts
|
|
8
|
+
* Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
|
|
9
|
+
* If force=true, aborts active sessions but still blocks on dirty worktrees.
|
|
10
|
+
* Pass acknowledge_uncommitted=true to remove dirty worktrees.
|
|
8
11
|
*/
|
|
9
12
|
export async function executeTeamCleanup(
|
|
10
13
|
deps: ToolDeps,
|
|
11
|
-
args: { force: boolean },
|
|
14
|
+
args: { force: boolean; acknowledge_uncommitted?: boolean },
|
|
12
15
|
sessionId: string,
|
|
16
|
+
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
13
17
|
): Promise<string> {
|
|
14
|
-
const teamInfo =
|
|
15
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
16
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
|
|
18
|
+
const teamInfo = requireLead(deps, sessionId)
|
|
17
19
|
|
|
18
|
-
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?")
|
|
19
|
-
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null }>
|
|
20
|
+
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
|
|
21
|
+
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null; workspace_id: string | null }>
|
|
20
22
|
|
|
21
23
|
const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
|
|
22
24
|
|
|
@@ -25,7 +27,28 @@ export async function executeTeamCleanup(
|
|
|
25
27
|
throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`)
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
//
|
|
30
|
+
// Check for uncommitted changes BEFORE aborting sessions — agents can still commit if warned
|
|
31
|
+
if (!args.acknowledge_uncommitted) {
|
|
32
|
+
const dirty: Array<{ name: string; branch: string }> = []
|
|
33
|
+
for (const member of members) {
|
|
34
|
+
if (member.worktree_dir) {
|
|
35
|
+
try {
|
|
36
|
+
if (await isDirty(member.worktree_dir)) {
|
|
37
|
+
dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" })
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
log(`cleanup:dirty-check:failed name=${member.name}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (dirty.length > 0) {
|
|
46
|
+
const warnings = dirty.map(d => ` - ${d.name} (branch: ${d.branch})`).join("\n")
|
|
47
|
+
return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:\n${warnings}\n\nCommit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Force-abort active members (only reached if worktrees are clean or acknowledged)
|
|
29
52
|
if (args.force) {
|
|
30
53
|
for (const member of active) {
|
|
31
54
|
try {
|
|
@@ -34,12 +57,19 @@ export async function executeTeamCleanup(
|
|
|
34
57
|
}
|
|
35
58
|
}
|
|
36
59
|
|
|
37
|
-
// Remove worktrees and collect branches for merging
|
|
60
|
+
// Remove workspaces, worktrees, and collect branches for merging
|
|
38
61
|
const branches: string[] = []
|
|
39
62
|
for (const member of members) {
|
|
63
|
+
if (member.workspace_id) {
|
|
64
|
+
try {
|
|
65
|
+
await deps.client.workspace.remove({ id: member.workspace_id })
|
|
66
|
+
deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
|
|
67
|
+
} catch { /* best effort — workspace may already be gone */ }
|
|
68
|
+
}
|
|
40
69
|
if (member.worktree_dir) {
|
|
41
70
|
try {
|
|
42
71
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
|
|
72
|
+
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
|
|
43
73
|
} catch { /* best effort — worktree may already be gone */ }
|
|
44
74
|
}
|
|
45
75
|
if (member.worktree_branch) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { resolveRecipientSession } from "../types"
|
|
3
|
+
import { requireTeamMember } from "./shared"
|
|
3
4
|
import { sendMessage, markDelivered } from "../messaging"
|
|
4
5
|
import { log } from "../log"
|
|
5
6
|
|
|
@@ -12,8 +13,7 @@ export async function executeTeamMessage(
|
|
|
12
13
|
args: { to: string; text: string; approve?: boolean; reject?: string },
|
|
13
14
|
sessionId: string,
|
|
14
15
|
): Promise<string> {
|
|
15
|
-
const teamInfo =
|
|
16
|
-
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
16
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
17
17
|
|
|
18
18
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
19
19
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/** Row shape for unread messages query. */
|
|
5
5
|
interface UnreadMessageRow {
|
|
@@ -18,8 +18,7 @@ export async function executeTeamResults(
|
|
|
18
18
|
args: { from?: string },
|
|
19
19
|
sessionId: string,
|
|
20
20
|
): Promise<string> {
|
|
21
|
-
const team =
|
|
22
|
-
if (!team) throw new Error("not in a team")
|
|
21
|
+
const team = requireTeamMember(deps, sessionId)
|
|
23
22
|
|
|
24
23
|
const rows = args.from
|
|
25
24
|
? (deps.db
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
3
|
+
import type { IsDirtyFn } from "./shared"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Execute the team_shutdown tool. Requests a teammate to shut down.
|
|
@@ -9,18 +10,19 @@ import { findTeamBySession } from "../types"
|
|
|
9
10
|
* - If member is idle or force=true, abort immediately and set status='shutdown'.
|
|
10
11
|
* - If member is busy and force=false, send a shutdown message via promptAsync
|
|
11
12
|
* and set status='shutdown_requested'. The member finishes work and reports back.
|
|
13
|
+
*
|
|
14
|
+
* Warns if the member's worktree has uncommitted changes.
|
|
12
15
|
*/
|
|
13
16
|
export async function executeTeamShutdown(
|
|
14
17
|
deps: ToolDeps,
|
|
15
18
|
args: { member: string; force?: boolean },
|
|
16
19
|
sessionId: string,
|
|
20
|
+
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
17
21
|
): Promise<string> {
|
|
18
|
-
const teamInfo =
|
|
19
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
20
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
|
|
22
|
+
const teamInfo = requireLead(deps, sessionId)
|
|
21
23
|
|
|
22
|
-
const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
23
|
-
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null } | null
|
|
24
|
+
const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
|
|
25
|
+
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null; worktree_dir: string | null } | null
|
|
24
26
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
25
27
|
if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
|
|
26
28
|
|
|
@@ -30,7 +32,8 @@ export async function executeTeamShutdown(
|
|
|
30
32
|
if (member.status === "shutdown_requested") {
|
|
31
33
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
|
|
32
34
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
|
|
33
|
-
|
|
35
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
|
|
36
|
+
return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
// Determine if member is idle or busy
|
|
@@ -46,7 +49,8 @@ export async function executeTeamShutdown(
|
|
|
46
49
|
if (isIdle || force) {
|
|
47
50
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
|
|
48
51
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
|
|
49
|
-
|
|
52
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
|
|
53
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
// Busy + not force → graceful: send shutdown message, set shutdown_requested
|
|
@@ -91,3 +95,14 @@ async function abortAndShutdown(
|
|
|
91
95
|
[Date.now(), teamId, memberName],
|
|
92
96
|
)
|
|
93
97
|
}
|
|
98
|
+
|
|
99
|
+
/** Build a warning suffix if the member's worktree has uncommitted changes. */
|
|
100
|
+
async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
|
|
101
|
+
if (!worktreeDir) return ""
|
|
102
|
+
try {
|
|
103
|
+
if (await isDirty(worktreeDir)) {
|
|
104
|
+
return `\n\nWarning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`
|
|
105
|
+
}
|
|
106
|
+
} catch { /* best effort */ }
|
|
107
|
+
return ""
|
|
108
|
+
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps, PermissionRule } from "../types"
|
|
2
2
|
import { validateMemberName } from "../util"
|
|
3
|
-
import {
|
|
3
|
+
import { requireLead } from "./shared"
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
5
|
import { log } from "../log"
|
|
6
6
|
|
|
@@ -9,6 +9,11 @@ function getSpawnTimeout(): number {
|
|
|
9
9
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Returns true if the directory is already inside an OpenCode worktree. */
|
|
13
|
+
function isWorktreeDirectory(dir: string): boolean {
|
|
14
|
+
return dir.includes("/opencode/worktree/")
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
/** Race a promise against a timeout. Throws if the timeout fires first. Cleans up timer on resolution. */
|
|
13
18
|
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
14
19
|
let timer: ReturnType<typeof setTimeout>
|
|
@@ -34,16 +39,14 @@ export async function executeTeamSpawn(
|
|
|
34
39
|
const nameError = validateMemberName(args.name)
|
|
35
40
|
if (nameError) throw new Error(nameError)
|
|
36
41
|
|
|
37
|
-
const teamInfo =
|
|
38
|
-
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
39
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can spawn teammates.")
|
|
42
|
+
const teamInfo = requireLead(deps, sessionId)
|
|
40
43
|
|
|
41
44
|
// Check duplicate name
|
|
42
45
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
|
|
43
46
|
.get(teamInfo.teamId, args.name)
|
|
44
47
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
45
48
|
|
|
46
|
-
const useWorktree = args.worktree !== false
|
|
49
|
+
const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory)
|
|
47
50
|
const usePlanApproval = args.plan_approval === true
|
|
48
51
|
|
|
49
52
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
|
|
@@ -78,20 +81,55 @@ export async function executeTeamSpawn(
|
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
// Create workspace from worktree branch — links session to worktree directory.
|
|
85
|
+
// OQ-workspace: assumes workspace.create({ branch }) auto-links to the worktree at that branch.
|
|
86
|
+
let workspaceId: string | null = null
|
|
87
|
+
if (worktreeDir && worktreeBranch) {
|
|
88
|
+
try {
|
|
89
|
+
log(`spawn:workspace:start name=${args.name}`)
|
|
90
|
+
const wsResult = await withTimeout(
|
|
91
|
+
deps.client.workspace.create({ branch: worktreeBranch }),
|
|
92
|
+
getSpawnTimeout(), `workspace.create for "${args.name}"`
|
|
93
|
+
)
|
|
94
|
+
if (wsResult.data) {
|
|
95
|
+
workspaceId = wsResult.data.id
|
|
96
|
+
}
|
|
97
|
+
log(`spawn:workspace:done name=${args.name} id=${workspaceId}`)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
100
|
+
// Non-fatal — prompt-based CWD instruction is the fallback
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
// Permission rules on session.create are the hard gate (server-enforced).
|
|
82
|
-
// For read-only agents, deny write tools and explicitly allow team tools
|
|
83
|
-
//
|
|
105
|
+
// For read-only agents, deny write tools and explicitly allow team tools.
|
|
106
|
+
// For all agents with worktrees, allowlist the worktree path for edit/bash.
|
|
84
107
|
const isReadOnly = args.agent === "plan" || args.agent === "explore"
|
|
85
108
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
|
|
86
|
-
const permission: PermissionRule[]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
const permission: PermissionRule[] = []
|
|
110
|
+
|
|
111
|
+
if (worktreeDir) {
|
|
112
|
+
permission.push(
|
|
113
|
+
{ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
|
|
114
|
+
)
|
|
115
|
+
if (!isReadOnly) {
|
|
116
|
+
permission.push({ permission: "bash", pattern: "*", action: "allow" })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isReadOnly) {
|
|
121
|
+
permission.push(
|
|
122
|
+
{ permission: "edit", pattern: "*", action: "deny" },
|
|
123
|
+
{ permission: "bash", pattern: "*", action: "deny" },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
permission.push(
|
|
128
|
+
...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Create child session — bind to workspace if available (server-enforced CWD isolation).
|
|
132
|
+
// Falls back to no workspace binding if workspace.create failed.
|
|
95
133
|
let childSessionId: string | undefined
|
|
96
134
|
try {
|
|
97
135
|
log(`spawn:session:start name=${args.name}`)
|
|
@@ -99,8 +137,8 @@ export async function executeTeamSpawn(
|
|
|
99
137
|
deps.client.session.create({
|
|
100
138
|
parentID: sessionId,
|
|
101
139
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
102
|
-
|
|
103
|
-
...(
|
|
140
|
+
permission,
|
|
141
|
+
...(workspaceId ? { workspaceID: workspaceId } : {}),
|
|
104
142
|
}),
|
|
105
143
|
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
106
144
|
)
|
|
@@ -108,7 +146,10 @@ export async function executeTeamSpawn(
|
|
|
108
146
|
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
|
|
109
147
|
} catch (err) {
|
|
110
148
|
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
111
|
-
// Rollback worktree if session creation failed
|
|
149
|
+
// Rollback workspace and worktree if session creation failed
|
|
150
|
+
if (workspaceId) {
|
|
151
|
+
try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
|
|
152
|
+
}
|
|
112
153
|
if (worktreeDir) {
|
|
113
154
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
114
155
|
}
|
|
@@ -116,6 +157,9 @@ export async function executeTeamSpawn(
|
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
if (!childSessionId) {
|
|
160
|
+
if (workspaceId) {
|
|
161
|
+
try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
|
|
162
|
+
}
|
|
119
163
|
if (worktreeDir) {
|
|
120
164
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
121
165
|
}
|
|
@@ -126,9 +170,9 @@ export async function executeTeamSpawn(
|
|
|
126
170
|
const planApproval = usePlanApproval ? "pending" : "none"
|
|
127
171
|
const now = Date.now()
|
|
128
172
|
deps.db.run(
|
|
129
|
-
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
|
|
130
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`,
|
|
131
|
-
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]
|
|
173
|
+
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
174
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
175
|
+
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
|
|
132
176
|
)
|
|
133
177
|
|
|
134
178
|
// Register in memory
|
|
@@ -140,8 +184,22 @@ export async function executeTeamSpawn(
|
|
|
140
184
|
`Your agent type is "${args.agent}".`,
|
|
141
185
|
]
|
|
142
186
|
|
|
143
|
-
if (worktreeBranch) {
|
|
144
|
-
|
|
187
|
+
if (worktreeBranch && worktreeDir && !workspaceId) {
|
|
188
|
+
// Workspace binding failed — fallback to prompt-based CWD instruction
|
|
189
|
+
context.push(
|
|
190
|
+
`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`,
|
|
191
|
+
`Your changes are isolated from other teammates.`,
|
|
192
|
+
`IMPORTANT: All file operations and shell commands MUST target your worktree directory.`,
|
|
193
|
+
`Before running shell commands, cd to: ${worktreeDir}`,
|
|
194
|
+
)
|
|
195
|
+
} else if (worktreeBranch && worktreeDir) {
|
|
196
|
+
// Workspace binding active — server handles CWD
|
|
197
|
+
context.push(
|
|
198
|
+
`You are working on branch "${worktreeBranch}" in your own isolated worktree.`,
|
|
199
|
+
`Your changes are isolated from other teammates.`,
|
|
200
|
+
)
|
|
201
|
+
} else if (worktreeBranch) {
|
|
202
|
+
context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`)
|
|
145
203
|
}
|
|
146
204
|
|
|
147
205
|
// Plan approval mode — teammate must send plan before writing
|
|
@@ -155,20 +213,55 @@ export async function executeTeamSpawn(
|
|
|
155
213
|
)
|
|
156
214
|
}
|
|
157
215
|
|
|
216
|
+
if (isReadOnly) {
|
|
217
|
+
context.push(
|
|
218
|
+
"", "Tools available to you:",
|
|
219
|
+
"- team_message: send a message to the lead or another teammate",
|
|
220
|
+
"- team_broadcast: send a message to all team members",
|
|
221
|
+
"- team_tasks_list: view the shared team task board",
|
|
222
|
+
)
|
|
223
|
+
} else {
|
|
224
|
+
context.push(
|
|
225
|
+
"", "Tools available to you:",
|
|
226
|
+
"- team_message: send a message to the lead or another teammate",
|
|
227
|
+
"- team_broadcast: send a message to all team members",
|
|
228
|
+
"- team_tasks_list: view the shared team task board",
|
|
229
|
+
"- team_tasks_add: add tasks to the shared board",
|
|
230
|
+
"- team_tasks_complete: mark a task complete on the shared board",
|
|
231
|
+
"- team_claim: claim a pending task from the shared board",
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
context.push("", "When you finish your task:")
|
|
236
|
+
if (!isReadOnly && worktreeBranch) {
|
|
237
|
+
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
|
|
238
|
+
context.push("2. If you claimed a task, mark it complete using team_tasks_complete.")
|
|
239
|
+
context.push(
|
|
240
|
+
"3. Send ONE message to the lead using team_message with this format:",
|
|
241
|
+
)
|
|
242
|
+
} else if (!isReadOnly) {
|
|
243
|
+
context.push("1. If you claimed a task, mark it complete using team_tasks_complete.")
|
|
244
|
+
context.push(
|
|
245
|
+
"2. Send ONE message to the lead using team_message with this format:",
|
|
246
|
+
)
|
|
247
|
+
} else {
|
|
248
|
+
context.push(
|
|
249
|
+
"1. Send ONE message to the lead using team_message with this format:",
|
|
250
|
+
)
|
|
251
|
+
}
|
|
158
252
|
context.push(
|
|
159
|
-
"",
|
|
160
|
-
"
|
|
161
|
-
"-
|
|
162
|
-
"
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
253
|
+
"<task-result>",
|
|
254
|
+
"<status>completed or failed</status>",
|
|
255
|
+
"<summary>One-line summary of what you did</summary>",
|
|
256
|
+
"<details>Full findings or changes made</details>",
|
|
257
|
+
)
|
|
258
|
+
if (worktreeBranch) {
|
|
259
|
+
context.push(`<branch>${worktreeBranch}</branch>`)
|
|
260
|
+
}
|
|
261
|
+
context.push("</task-result>")
|
|
262
|
+
const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2"
|
|
263
|
+
context.push(
|
|
264
|
+
`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
|
|
172
265
|
"",
|
|
173
266
|
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
174
267
|
"",
|
|
@@ -200,6 +293,9 @@ export async function executeTeamSpawn(
|
|
|
200
293
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
201
294
|
deps.registry.unregister(childSessionId)
|
|
202
295
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
296
|
+
if (workspaceId) {
|
|
297
|
+
deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
|
|
298
|
+
}
|
|
203
299
|
if (worktreeDir) {
|
|
204
300
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
205
301
|
}
|
package/src/tools/team-status.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/** Tracks the last time team_status was called per team ID (epoch ms). */
|
|
5
5
|
export const lastCallTime = new Map<string, number>()
|
|
@@ -13,8 +13,7 @@ export async function executeTeamStatus(
|
|
|
13
13
|
deps: ToolDeps,
|
|
14
14
|
sessionId: string,
|
|
15
15
|
): Promise<string> {
|
|
16
|
-
const teamInfo =
|
|
17
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
16
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
18
17
|
|
|
19
18
|
const now = Date.now()
|
|
20
19
|
const last = lastCallTime.get(teamInfo.teamId)
|
|
@@ -24,9 +23,9 @@ export async function executeTeamStatus(
|
|
|
24
23
|
lastCallTime.set(teamInfo.teamId, now)
|
|
25
24
|
|
|
26
25
|
const members = deps.db.query(
|
|
27
|
-
"SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
26
|
+
"SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
28
27
|
).all(teamInfo.teamId) as Array<{
|
|
29
|
-
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; plan_approval: string
|
|
28
|
+
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string
|
|
30
29
|
}>
|
|
31
30
|
|
|
32
31
|
const tasks = deps.db.query(
|
|
@@ -46,6 +45,9 @@ export async function executeTeamStatus(
|
|
|
46
45
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
|
|
47
46
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
|
|
48
47
|
lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
|
|
48
|
+
if (m.worktree_dir) {
|
|
49
|
+
lines.push(` worktree: ${m.worktree_dir}`)
|
|
50
|
+
}
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { generateId } from "../util"
|
|
4
4
|
|
|
5
5
|
interface TaskInput {
|
|
@@ -17,8 +17,7 @@ export async function executeTeamTasksAdd(
|
|
|
17
17
|
args: { tasks: TaskInput[] },
|
|
18
18
|
sessionId: string,
|
|
19
19
|
): Promise<string> {
|
|
20
|
-
const teamInfo =
|
|
21
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
20
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
22
21
|
|
|
23
22
|
const ids: string[] = []
|
|
24
23
|
const now = Date.now()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_tasks_complete tool. Marks a task as completed
|
|
@@ -10,8 +10,7 @@ export async function executeTeamTasksComplete(
|
|
|
10
10
|
args: { task_id: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
|
-
const teamInfo =
|
|
14
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
15
14
|
|
|
16
15
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
|
|
17
16
|
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_tasks_list tool. Shows all tasks on the shared board.
|
|
@@ -8,8 +8,7 @@ export async function executeTeamTasksList(
|
|
|
8
8
|
deps: ToolDeps,
|
|
9
9
|
sessionId: string,
|
|
10
10
|
): Promise<string> {
|
|
11
|
-
const teamInfo =
|
|
12
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
11
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
13
12
|
|
|
14
13
|
const tasks = deps.db.query(
|
|
15
14
|
"SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC"
|
package/src/tools/team-view.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_view tool. Navigates the TUI to a teammate's session
|
|
@@ -10,8 +10,7 @@ export async function executeTeamView(
|
|
|
10
10
|
args: { member: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
|
-
const teamInfo =
|
|
14
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
15
14
|
|
|
16
15
|
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?")
|
|
17
16
|
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
|
package/src/types.ts
CHANGED
|
@@ -62,6 +62,11 @@ export interface PluginClient {
|
|
|
62
62
|
list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
|
|
63
63
|
reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
|
|
64
64
|
}
|
|
65
|
+
workspace: {
|
|
66
|
+
create(options: { id?: string; type?: string; branch?: string | null; extra?: unknown | null }): Promise<{ data?: { id: string; type: string; branch: string | null; directory: string | null; projectID: string } }>
|
|
67
|
+
remove(options: { id: string }): Promise<unknown>
|
|
68
|
+
list(): Promise<{ data?: Array<{ id: string; type: string; branch: string | null; directory: string | null; projectID: string }> }>
|
|
69
|
+
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|