opencode-multiagent 0.2.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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/agents/advisor.md +57 -0
  4. package/agents/auditor.md +45 -0
  5. package/agents/critic.md +127 -0
  6. package/agents/deep-worker.md +65 -0
  7. package/agents/devil.md +36 -0
  8. package/agents/executor.md +141 -0
  9. package/agents/heavy-worker.md +68 -0
  10. package/agents/lead.md +155 -0
  11. package/agents/librarian.md +62 -0
  12. package/agents/planner.md +121 -0
  13. package/agents/qa.md +50 -0
  14. package/agents/quick.md +65 -0
  15. package/agents/reviewer.md +55 -0
  16. package/agents/scout.md +58 -0
  17. package/agents/scribe.md +78 -0
  18. package/agents/strategist.md +63 -0
  19. package/agents/ui-heavy-worker.md +62 -0
  20. package/agents/ui-worker.md +69 -0
  21. package/agents/validator.md +47 -0
  22. package/agents/worker.md +68 -0
  23. package/commands/execute.md +14 -0
  24. package/commands/init-deep.md +18 -0
  25. package/commands/init.md +18 -0
  26. package/commands/inspect.md +13 -0
  27. package/commands/plan.md +15 -0
  28. package/commands/quality.md +14 -0
  29. package/commands/review.md +14 -0
  30. package/commands/status.md +15 -0
  31. package/defaults/agent-settings.json +102 -0
  32. package/defaults/agent-settings.schema.json +25 -0
  33. package/defaults/flags.json +35 -0
  34. package/defaults/flags.schema.json +119 -0
  35. package/defaults/mcp-defaults.json +47 -0
  36. package/defaults/mcp-defaults.schema.json +38 -0
  37. package/defaults/profiles.json +53 -0
  38. package/defaults/profiles.schema.json +60 -0
  39. package/defaults/team-profiles.json +83 -0
  40. package/examples/opencode.json +4 -0
  41. package/examples/opencode.with-overrides.json +23 -0
  42. package/package.json +62 -0
  43. package/skills/advanced-evaluation/SKILL.md +454 -0
  44. package/skills/advanced-evaluation/manifest.json +20 -0
  45. package/skills/cek-context-engineering/SKILL.md +1261 -0
  46. package/skills/cek-context-engineering/manifest.json +17 -0
  47. package/skills/cek-prompt-engineering/SKILL.md +559 -0
  48. package/skills/cek-prompt-engineering/manifest.json +17 -0
  49. package/skills/cek-test-prompt/SKILL.md +714 -0
  50. package/skills/cek-test-prompt/manifest.json +17 -0
  51. package/skills/cek-thought-based-reasoning/SKILL.md +658 -0
  52. package/skills/cek-thought-based-reasoning/manifest.json +17 -0
  53. package/skills/context-degradation/SKILL.md +231 -0
  54. package/skills/context-degradation/manifest.json +17 -0
  55. package/skills/debate/SKILL.md +316 -0
  56. package/skills/debate/manifest.json +19 -0
  57. package/skills/design-first/SKILL.md +5 -0
  58. package/skills/design-first/manifest.json +20 -0
  59. package/skills/dispatching-parallel-agents/SKILL.md +180 -0
  60. package/skills/dispatching-parallel-agents/manifest.json +18 -0
  61. package/skills/drift-analysis/SKILL.md +324 -0
  62. package/skills/drift-analysis/manifest.json +19 -0
  63. package/skills/evaluation/SKILL.md +5 -0
  64. package/skills/evaluation/manifest.json +19 -0
  65. package/skills/executing-plans/SKILL.md +70 -0
  66. package/skills/executing-plans/manifest.json +17 -0
  67. package/skills/handoff-protocols/SKILL.md +5 -0
  68. package/skills/handoff-protocols/manifest.json +19 -0
  69. package/skills/parallel-investigation/SKILL.md +206 -0
  70. package/skills/parallel-investigation/manifest.json +18 -0
  71. package/skills/reflexion-critique/SKILL.md +477 -0
  72. package/skills/reflexion-critique/manifest.json +17 -0
  73. package/skills/reflexion-reflect/SKILL.md +650 -0
  74. package/skills/reflexion-reflect/manifest.json +17 -0
  75. package/skills/root-cause-analysis/SKILL.md +5 -0
  76. package/skills/root-cause-analysis/manifest.json +20 -0
  77. package/skills/sadd-judge-with-debate/SKILL.md +426 -0
  78. package/skills/sadd-judge-with-debate/manifest.json +17 -0
  79. package/skills/structured-code-review/SKILL.md +5 -0
  80. package/skills/structured-code-review/manifest.json +18 -0
  81. package/skills/task-decomposition/SKILL.md +5 -0
  82. package/skills/task-decomposition/manifest.json +20 -0
  83. package/skills/verification-before-completion/SKILL.md +5 -0
  84. package/skills/verification-before-completion/manifest.json +22 -0
  85. package/skills/verification-gates/SKILL.md +281 -0
  86. package/skills/verification-gates/manifest.json +19 -0
  87. package/src/control-plane.ts +21 -0
  88. package/src/index.ts +8 -0
  89. package/src/opencode-multiagent/compiler.ts +168 -0
  90. package/src/opencode-multiagent/constants.ts +178 -0
  91. package/src/opencode-multiagent/file-lock.ts +90 -0
  92. package/src/opencode-multiagent/hooks.ts +599 -0
  93. package/src/opencode-multiagent/log.ts +12 -0
  94. package/src/opencode-multiagent/mailbox.ts +287 -0
  95. package/src/opencode-multiagent/markdown.ts +99 -0
  96. package/src/opencode-multiagent/mcp.ts +35 -0
  97. package/src/opencode-multiagent/policy.ts +67 -0
  98. package/src/opencode-multiagent/quality.ts +140 -0
  99. package/src/opencode-multiagent/runtime.ts +55 -0
  100. package/src/opencode-multiagent/skills.ts +144 -0
  101. package/src/opencode-multiagent/supervision.ts +156 -0
  102. package/src/opencode-multiagent/task-manager.ts +148 -0
  103. package/src/opencode-multiagent/team-manager.ts +219 -0
  104. package/src/opencode-multiagent/team-tools.ts +359 -0
  105. package/src/opencode-multiagent/telemetry.ts +124 -0
  106. package/src/opencode-multiagent/utils.ts +54 -0
@@ -0,0 +1,144 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ import { packageRoot } from "./constants.ts";
6
+ import { note } from "./log.ts";
7
+
8
+ type SkillManifest = {
9
+ name: string;
10
+ triggers: string[];
11
+ applicable_agents: string[];
12
+ max_context_tokens: number;
13
+ entry_file?: string;
14
+ };
15
+
16
+ type SkillEntry = {
17
+ path: string;
18
+ manifest: SkillManifest;
19
+ content: string;
20
+ };
21
+
22
+ const resolveSource = (value: unknown): string =>
23
+ String(value ?? "")
24
+ .replaceAll("${plugin_root}", packageRoot)
25
+ .replaceAll("${home}", homedir());
26
+
27
+ const uniqueSources = (sources: unknown[]): string[] =>
28
+ [...new Set((sources ?? []).map(resolveSource).filter(Boolean).map((value) => resolve(value)))];
29
+
30
+ const isStringArray = (value: unknown): value is string[] =>
31
+ Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim().length > 0);
32
+
33
+ const normalizeManifest = (manifest: SkillManifest): SkillManifest => ({
34
+ ...manifest,
35
+ entry_file: typeof manifest.entry_file === "string" && manifest.entry_file.trim() ? manifest.entry_file : "SKILL.md",
36
+ triggers: manifest.triggers.map((trigger) => trigger.toLowerCase().trim()),
37
+ applicable_agents: manifest.applicable_agents.map((agent) => agent.trim()),
38
+ });
39
+
40
+ const validManifest = (manifest: unknown): manifest is SkillManifest => {
41
+ if (!manifest || typeof manifest !== "object") return false;
42
+ const candidate = manifest as SkillManifest;
43
+ if (typeof candidate.name !== "string" || candidate.name.trim().length === 0) return false;
44
+ if (!isStringArray(candidate.triggers)) return false;
45
+ if (!isStringArray(candidate.applicable_agents)) return false;
46
+ if (typeof candidate.max_context_tokens !== "number" || candidate.max_context_tokens <= 0) return false;
47
+ if (candidate.entry_file != null && (typeof candidate.entry_file !== "string" || candidate.entry_file.trim().length === 0)) {
48
+ return false;
49
+ }
50
+ return true;
51
+ };
52
+
53
+ export async function loadSkillRegistry(sources: unknown[], flags: Record<string, unknown> = {}): Promise<Map<string, SkillEntry>> {
54
+ const registry = new Map<string, SkillEntry>();
55
+
56
+ for (const source of uniqueSources(sources)) {
57
+ const entries = await readdir(source, { withFileTypes: true }).catch(() => []);
58
+ for (const entry of entries.filter((item) => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name))) {
59
+ const skillDir = join(source, entry.name);
60
+ const manifestPath = join(skillDir, "manifest.json");
61
+ const manifest = await readFile(manifestPath, "utf8")
62
+ .then((value) => JSON.parse(value) as unknown)
63
+ .catch(async () => {
64
+ await note("skill_registry_warning", {
65
+ observation: flags.observation === true,
66
+ warning: "invalid_manifest",
67
+ source: skillDir,
68
+ });
69
+ return null;
70
+ });
71
+ if (!validManifest(manifest)) {
72
+ if (manifest !== null) {
73
+ await note("skill_registry_warning", {
74
+ observation: flags.observation === true,
75
+ warning: "invalid_manifest",
76
+ source: skillDir,
77
+ });
78
+ }
79
+ continue;
80
+ }
81
+
82
+ const normalized = normalizeManifest(manifest);
83
+ if (registry.has(normalized.name)) {
84
+ await note("skill_registry_warning", {
85
+ observation: flags.observation === true,
86
+ warning: "duplicate_skill",
87
+ skill: normalized.name,
88
+ source: skillDir,
89
+ });
90
+ continue;
91
+ }
92
+
93
+ const content = await readFile(join(skillDir, normalized.entry_file!), "utf8").catch(async () => {
94
+ await note("skill_registry_warning", {
95
+ observation: flags.observation === true,
96
+ warning: "invalid_skill_content",
97
+ skill: normalized.name,
98
+ source: skillDir,
99
+ });
100
+ return null;
101
+ });
102
+ if (typeof content !== "string" || content.trim().length === 0) continue;
103
+
104
+ registry.set(normalized.name, {
105
+ path: skillDir,
106
+ manifest: normalized,
107
+ content: content.trim(),
108
+ });
109
+ }
110
+ }
111
+
112
+ if (registry.size === 0) {
113
+ await note("skill_registry_warning", {
114
+ observation: flags.observation === true,
115
+ warning: "empty_registry",
116
+ });
117
+ }
118
+
119
+ return registry;
120
+ }
121
+
122
+ export function findSkillsForTask(registry: Map<string, SkillEntry>, taskText: unknown, agentName: unknown): SkillEntry[] {
123
+ const text = String(taskText ?? "").toLowerCase();
124
+ const agent = String(agentName ?? "").trim();
125
+ const matches: Array<{ score: number; skill: SkillEntry }> = [];
126
+
127
+ for (const skill of registry.values()) {
128
+ if (!skill.manifest.applicable_agents.includes(agent)) continue;
129
+ const score = skill.manifest.triggers.reduce((count, trigger) => count + (text.includes(trigger) ? 1 : 0), 0);
130
+ if (score === 0) continue;
131
+ matches.push({ score, skill });
132
+ }
133
+
134
+ return matches
135
+ .sort((left, right) => right.score - left.score || left.skill.manifest.name.localeCompare(right.skill.manifest.name))
136
+ .slice(0, 3)
137
+ .map((entry) => entry.skill);
138
+ }
139
+
140
+ export function injectSkills(skills: SkillEntry[] = []): string {
141
+ const entries = skills.filter((skill) => typeof skill?.content === "string" && skill.content.trim().length > 0);
142
+ if (entries.length === 0) return "";
143
+ return entries.map((skill) => `## Skill: ${skill.manifest.name}\n${skill.content.trim()}`).join("\n\n");
144
+ }
@@ -0,0 +1,156 @@
1
+ import { supervisionEventTypes } from "./constants.ts";
2
+ import { note } from "./log.ts";
3
+
4
+ const cleanupIntervalMs = 5 * 60 * 1000;
5
+ const staleChildTtlMs = 30 * 60 * 1000;
6
+ const maxTrackedChildren = 100;
7
+ const evictionFraction = 0.2;
8
+
9
+ type ChildInfo = {
10
+ parentID: string;
11
+ agentName: string | null;
12
+ lastActivity: number;
13
+ remindedAt: number;
14
+ };
15
+
16
+ type PromptClient = {
17
+ session?: {
18
+ prompt?: (options: {
19
+ path: { id: string };
20
+ body?: { parts: Array<{ type: string; text: string }>; noReply?: boolean };
21
+ }) => Promise<unknown>;
22
+ };
23
+ };
24
+
25
+ export const createSupervisionController = ({ flags, client }: { flags: Record<string, any>; client: PromptClient }) => {
26
+ const childMap = new Map<string, Set<string>>();
27
+ const childInfo = new Map<string, ChildInfo>();
28
+
29
+ const removeChild = (childID: string): boolean => {
30
+ const info = childInfo.get(childID);
31
+ if (!info) return false;
32
+ childInfo.delete(childID);
33
+ const children = childMap.get(info.parentID);
34
+ if (!children) return true;
35
+ children.delete(childID);
36
+ if (children.size === 0) childMap.delete(info.parentID);
37
+ return true;
38
+ };
39
+
40
+ const cleanupStaleChildren = (now = Date.now()): void => {
41
+ for (const [childID, info] of childInfo.entries()) {
42
+ if (now - info.lastActivity > staleChildTtlMs) removeChild(childID);
43
+ }
44
+ };
45
+
46
+ const evictOldestChildren = (count: number): void => {
47
+ if (count <= 0) return;
48
+ const childIDs = [...childInfo.entries()]
49
+ .sort((left, right) => left[1].lastActivity - right[1].lastActivity)
50
+ .slice(0, count)
51
+ .map(([childID]) => childID);
52
+ for (const childID of childIDs) removeChild(childID);
53
+ };
54
+
55
+ const enforceChildLimit = (): void => {
56
+ if (childInfo.size <= maxTrackedChildren) return;
57
+ evictOldestChildren(Math.max(1, Math.ceil(childInfo.size * evictionFraction)));
58
+ };
59
+
60
+ let interval: ReturnType<typeof setInterval> | null = null;
61
+ if (flags.supervision) {
62
+ interval = setInterval(() => {
63
+ cleanupStaleChildren();
64
+ }, cleanupIntervalMs);
65
+ interval.unref?.();
66
+ }
67
+
68
+ const cleanup = (): void => {
69
+ if (!interval) return;
70
+ clearInterval(interval);
71
+ interval = null;
72
+ };
73
+
74
+ const handleSupervision = async (event: { type?: string; properties?: any }): Promise<void> => {
75
+ if (!flags.supervision || !supervisionEventTypes.has(event?.type ?? "")) return;
76
+ const props = event.properties ?? {};
77
+
78
+ if (event.type === "session.created") {
79
+ const parentID = typeof props.info?.parentID === "string" ? props.info.parentID : undefined;
80
+ const childID =
81
+ typeof props.sessionID === "string" ? props.sessionID : typeof props.info?.id === "string" ? props.info.id : undefined;
82
+ if (!parentID || !childID) return;
83
+ removeChild(childID);
84
+ if (!childMap.has(parentID)) childMap.set(parentID, new Set());
85
+ childMap.get(parentID)?.add(childID);
86
+ childInfo.set(childID, {
87
+ parentID,
88
+ agentName: null,
89
+ lastActivity: Date.now(),
90
+ remindedAt: 0,
91
+ });
92
+ enforceChildLimit();
93
+ await note("supervision", { event: "child_tracked", parentID, childID });
94
+ return;
95
+ }
96
+
97
+ if (["message.updated", "message.part.updated", "message.part.delta"].includes(event.type ?? "")) {
98
+ const sessionID =
99
+ event.type === "message.updated"
100
+ ? typeof props.info?.sessionID === "string"
101
+ ? props.info.sessionID
102
+ : undefined
103
+ : event.type === "message.part.updated"
104
+ ? typeof props.part?.sessionID === "string"
105
+ ? props.part.sessionID
106
+ : undefined
107
+ : typeof props.sessionID === "string"
108
+ ? props.sessionID
109
+ : undefined;
110
+ if (!sessionID || !childInfo.has(sessionID)) return;
111
+ const info = childInfo.get(sessionID)!;
112
+ info.lastActivity = Date.now();
113
+ if (event.type === "message.updated" && props.info?.role === "assistant" && typeof props.info?.agent === "string") {
114
+ info.agentName = props.info.agent;
115
+ }
116
+ return;
117
+ }
118
+
119
+ if (event.type === "session.idle") {
120
+ const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined;
121
+ if (!sessionID || !childInfo.has(sessionID) || !client?.session?.prompt) return;
122
+ const info = childInfo.get(sessionID)!;
123
+ const now = Date.now();
124
+ const idleTimeout = flags.supervision_config?.idle_timeout_ms ?? 180000;
125
+ const cooldown = flags.supervision_config?.cooldown_ms ?? 300000;
126
+ if (now - info.lastActivity < idleTimeout || now - info.remindedAt < cooldown) return;
127
+ info.remindedAt = now;
128
+ const reminderText =
129
+ `[opencode-multiagent supervision] Child session ${sessionID} ` +
130
+ `(agent: ${info.agentName ?? "unknown"}) has been idle. Check status or resume if needed.`;
131
+ try {
132
+ await client.session.prompt({
133
+ path: { id: info.parentID },
134
+ body: { parts: [{ type: "text", text: reminderText }], noReply: true },
135
+ });
136
+ } catch {
137
+ return;
138
+ }
139
+ await note("supervision", {
140
+ event: "idle_reminder",
141
+ parentID: info.parentID,
142
+ childID: sessionID,
143
+ agentName: info.agentName,
144
+ });
145
+ return;
146
+ }
147
+
148
+ if (event.type === "session.deleted") {
149
+ const sessionID = typeof props.info?.id === "string" ? props.info.id : undefined;
150
+ if (!sessionID || !removeChild(sessionID)) return;
151
+ await note("supervision", { event: "child_removed", sessionID });
152
+ }
153
+ };
154
+
155
+ return { handleSupervision, cleanup };
156
+ };
@@ -0,0 +1,148 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export type TaskStatus = "pending" | "claimed" | "in_progress" | "completed" | "failed" | "blocked";
5
+ export type TaskPriority = "high" | "medium" | "low";
6
+
7
+ export type Task = {
8
+ id: string;
9
+ title: string;
10
+ description: string;
11
+ status: TaskStatus;
12
+ priority: TaskPriority;
13
+ assignedAgent?: string;
14
+ claimedBy?: string;
15
+ createdBy?: string;
16
+ dependencies: string[];
17
+ result?: string;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ };
21
+
22
+ export type CreateTaskInput = {
23
+ title: string;
24
+ description: string;
25
+ assignedAgent?: string;
26
+ dependencies?: string[];
27
+ priority?: TaskPriority;
28
+ createdBy?: string;
29
+ };
30
+
31
+ export type UpdateTaskInput = {
32
+ status?: TaskStatus;
33
+ result?: string;
34
+ assignedAgent?: string;
35
+ };
36
+
37
+ export type TaskFilter = {
38
+ status?: string;
39
+ assignedAgent?: string;
40
+ };
41
+
42
+ let taskCounter = 0;
43
+
44
+ const generateTaskID = (): string => {
45
+ taskCounter += 1;
46
+ return `T-${Date.now()}-${taskCounter.toString().padStart(4, "0")}`;
47
+ };
48
+
49
+ /**
50
+ * In-memory task board with optional JSON persistence under .magent/tasks/taskboard.json.
51
+ * All mutating operations fire-and-forget a persist() call — failures are non-fatal.
52
+ */
53
+ export const createTaskManager = (projectRoot?: string) => {
54
+ const tasks = new Map<string, Task>();
55
+ const boardPath = projectRoot ? join(projectRoot, ".magent", "tasks", "taskboard.json") : undefined;
56
+
57
+ const persist = async (): Promise<void> => {
58
+ if (!boardPath) return;
59
+ try {
60
+ await mkdir(join(boardPath, ".."), { recursive: true });
61
+ await writeFile(boardPath, JSON.stringify([...tasks.values()], null, 2), "utf8");
62
+ } catch {
63
+ // non-fatal
64
+ }
65
+ };
66
+
67
+ const load = async (): Promise<void> => {
68
+ if (!boardPath) return;
69
+ try {
70
+ const raw = await readFile(boardPath, "utf8");
71
+ const items = JSON.parse(raw) as Task[];
72
+ for (const item of items) tasks.set(item.id, item);
73
+ } catch {
74
+ // non-fatal — board may not exist yet
75
+ }
76
+ };
77
+
78
+ const create = (input: CreateTaskInput): Task => {
79
+ const task: Task = {
80
+ id: generateTaskID(),
81
+ title: input.title,
82
+ description: input.description,
83
+ status: "pending",
84
+ priority: input.priority ?? "medium",
85
+ assignedAgent: input.assignedAgent,
86
+ createdBy: input.createdBy,
87
+ dependencies: input.dependencies ?? [],
88
+ createdAt: Date.now(),
89
+ updatedAt: Date.now(),
90
+ };
91
+ tasks.set(task.id, task);
92
+ void persist();
93
+ return task;
94
+ };
95
+
96
+ const get = (taskID: string): Task | undefined => tasks.get(taskID);
97
+
98
+ const update = (taskID: string, input: UpdateTaskInput): Task => {
99
+ const task = tasks.get(taskID);
100
+ if (!task) throw new Error(`Task ${taskID} not found`);
101
+ if (input.status !== undefined) task.status = input.status;
102
+ if (input.result !== undefined) task.result = input.result;
103
+ if (input.assignedAgent !== undefined) task.assignedAgent = input.assignedAgent;
104
+ task.updatedAt = Date.now();
105
+ void persist();
106
+ return task;
107
+ };
108
+
109
+ const claim = (taskID: string, sessionID: string): Task => {
110
+ const task = tasks.get(taskID);
111
+ if (!task) throw new Error(`Task ${taskID} not found`);
112
+ task.claimedBy = sessionID;
113
+ task.status = "claimed";
114
+ task.updatedAt = Date.now();
115
+ void persist();
116
+ return task;
117
+ };
118
+
119
+ const complete = (taskID: string, result?: string): Task => {
120
+ const task = tasks.get(taskID);
121
+ if (!task) throw new Error(`Task ${taskID} not found`);
122
+ task.status = "completed";
123
+ if (result !== undefined) task.result = result;
124
+ task.updatedAt = Date.now();
125
+ void persist();
126
+ return task;
127
+ };
128
+
129
+ const list = (filter?: TaskFilter): Task[] => {
130
+ const all = [...tasks.values()];
131
+ if (!filter) return all;
132
+ return all.filter((task) => {
133
+ if (filter.status && task.status !== filter.status) return false;
134
+ if (filter.assignedAgent && task.assignedAgent !== filter.assignedAgent) return false;
135
+ return true;
136
+ });
137
+ };
138
+
139
+ const getDependencyReady = (taskID: string): boolean => {
140
+ const task = tasks.get(taskID);
141
+ if (!task) return false;
142
+ return task.dependencies.every((depID) => tasks.get(depID)?.status === "completed");
143
+ };
144
+
145
+ return { create, get, update, claim, complete, list, getDependencyReady, persist, load };
146
+ };
147
+
148
+ export type TaskManager = ReturnType<typeof createTaskManager>;
@@ -0,0 +1,219 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { packageRoot } from "./constants.ts";
5
+ import { note } from "./log.ts";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Team member types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type TeamMemberStatus = "spawning" | "active" | "idle" | "done" | "error";
12
+
13
+ export type TeamMember = {
14
+ name: string;
15
+ sessionID: string;
16
+ agent: string;
17
+ role?: string;
18
+ status: TeamMemberStatus;
19
+ spawnedAt: number;
20
+ };
21
+
22
+ export type TeamStatus = {
23
+ leadSessionID: string | null;
24
+ memberCount: number;
25
+ members: Array<{
26
+ name: string;
27
+ sessionID: string;
28
+ agent: string;
29
+ role?: string;
30
+ status: TeamMemberStatus;
31
+ }>;
32
+ };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Team profile types (defaults/team-profiles.json schema)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export type TeamProfileMember = {
39
+ name: string;
40
+ agent: string;
41
+ role?: string;
42
+ /** When true the member is spawned automatically when the profile is activated. */
43
+ auto_spawn: boolean;
44
+ /** Initial prompt injected into the session when auto_spawn is true. */
45
+ initial_prompt?: string;
46
+ };
47
+
48
+ export type TeamProfile = {
49
+ description: string;
50
+ members: TeamProfileMember[];
51
+ };
52
+
53
+ export type TeamProfileRegistry = Record<string, TeamProfile>;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Profile loader (mirrors loadMcpDefaults pattern)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const profilesFilePath = join(packageRoot, "defaults", "team-profiles.json");
60
+ let cachedProfiles: TeamProfileRegistry | undefined;
61
+
62
+ /**
63
+ * Load team profiles from `defaults/team-profiles.json`.
64
+ * Results are cached after the first successful read.
65
+ */
66
+ export const loadTeamProfiles = async (): Promise<TeamProfileRegistry> => {
67
+ if (cachedProfiles) return cachedProfiles;
68
+ try {
69
+ const parsed = JSON.parse(await readFile(profilesFilePath, "utf8")) as TeamProfileRegistry;
70
+ cachedProfiles = parsed && typeof parsed === "object" ? parsed : {};
71
+ } catch {
72
+ cachedProfiles = {};
73
+ await note("config_warning", {
74
+ observation: true,
75
+ warning: "team_profiles_unreadable",
76
+ path: profilesFilePath,
77
+ });
78
+ }
79
+ return cachedProfiles;
80
+ };
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // TeamManager factory
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * In-memory registry of team members for a single lead session.
88
+ *
89
+ * The lead session ID is set lazily on the first `register()` call that
90
+ * provides a `leadSessionID`, so the manager can be constructed before the
91
+ * lead session is known.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * const tm = createTeamManager();
96
+ * tm.setLead(ctx.sessionID);
97
+ * const member = tm.register("backend-dev", {
98
+ * sessionID: "child-123",
99
+ * agent: "backend-dev",
100
+ * status: "spawning",
101
+ * });
102
+ * ```
103
+ */
104
+ export const createTeamManager = () => {
105
+ let leadSessionID: string | null = null;
106
+ /** name → member */
107
+ const members = new Map<string, TeamMember>();
108
+ /** sessionID → member name (reverse index) */
109
+ const sessionIndex = new Map<string, string>();
110
+
111
+ /** Set the lead session ID (idempotent — first caller wins). */
112
+ const setLead = (sessionID: string): void => {
113
+ if (!leadSessionID) leadSessionID = sessionID;
114
+ };
115
+
116
+ /**
117
+ * Register a new team member. Overwrites any existing entry with the same name.
118
+ */
119
+ const register = (
120
+ name: string,
121
+ info: { sessionID: string; agent: string; role?: string; status?: TeamMemberStatus },
122
+ ): TeamMember => {
123
+ // Remove stale index entry if the name was previously registered.
124
+ const existing = members.get(name);
125
+ if (existing) sessionIndex.delete(existing.sessionID);
126
+
127
+ const member: TeamMember = {
128
+ name,
129
+ sessionID: info.sessionID,
130
+ agent: info.agent,
131
+ role: info.role,
132
+ status: info.status ?? "spawning",
133
+ spawnedAt: Date.now(),
134
+ };
135
+ members.set(name, member);
136
+ sessionIndex.set(info.sessionID, name);
137
+ return member;
138
+ };
139
+
140
+ /** Look up a member by their logical team name. */
141
+ const getMemberByName = (name: string): TeamMember | undefined => members.get(name);
142
+
143
+ /** Look up a member by their OpenCode session ID. */
144
+ const getMemberBySessionID = (sessionID: string): TeamMember | undefined => {
145
+ const name = sessionIndex.get(sessionID);
146
+ return name ? members.get(name) : undefined;
147
+ };
148
+
149
+ /**
150
+ * Update the status of a member identified by session ID.
151
+ * No-op if the session ID is not tracked.
152
+ */
153
+ const setMemberStatus = (sessionID: string, status: TeamMemberStatus): void => {
154
+ const member = getMemberBySessionID(sessionID);
155
+ if (member) member.status = status;
156
+ };
157
+
158
+ /**
159
+ * Remove a member by their session ID.
160
+ * Called when a child session is deleted.
161
+ */
162
+ const remove = (sessionID: string): void => {
163
+ const name = sessionIndex.get(sessionID);
164
+ if (!name) return;
165
+ sessionIndex.delete(sessionID);
166
+ members.delete(name);
167
+ };
168
+
169
+ /**
170
+ * Resolve an agent name or raw session ID to a canonical session ID.
171
+ *
172
+ * - If the string matches a known member name → return that member's sessionID.
173
+ * - If the string is itself a tracked sessionID → return it unchanged.
174
+ * - Otherwise → return `undefined` (caller should treat the value as a raw ID).
175
+ */
176
+ const resolveSessionID = (nameOrSessionID: string): string | undefined => {
177
+ const byName = members.get(nameOrSessionID);
178
+ if (byName) return byName.sessionID;
179
+ if (sessionIndex.has(nameOrSessionID)) return nameOrSessionID;
180
+ return undefined;
181
+ };
182
+
183
+ /** Return a snapshot of the current team state. */
184
+ const getStatus = (): TeamStatus => ({
185
+ leadSessionID,
186
+ memberCount: members.size,
187
+ members: [...members.values()].map(({ name, sessionID, agent, role, status }) => ({
188
+ name,
189
+ sessionID,
190
+ agent,
191
+ role,
192
+ status,
193
+ })),
194
+ });
195
+
196
+ /** Release all in-memory state. Call on plugin teardown. */
197
+ const cleanup = (): void => {
198
+ members.clear();
199
+ sessionIndex.clear();
200
+ leadSessionID = null;
201
+ };
202
+
203
+ return {
204
+ get leadSessionID() {
205
+ return leadSessionID;
206
+ },
207
+ setLead,
208
+ register,
209
+ getMemberByName,
210
+ getMemberBySessionID,
211
+ setMemberStatus,
212
+ remove,
213
+ resolveSessionID,
214
+ getStatus,
215
+ cleanup,
216
+ };
217
+ };
218
+
219
+ export type TeamManager = ReturnType<typeof createTeamManager>;