@doingdev/opencode-claude-manager-plugin 0.1.58 → 0.1.59

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.
@@ -3,12 +3,46 @@ import { managerPromptRegistry } from '../prompts/registry.js';
3
3
  import { isEngineerName } from '../team/roster.js';
4
4
  import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
5
5
  import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, buildBrowserQaAgentConfig, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from './agents/index.js';
6
- import { getActiveTeamSession, getOrCreatePluginServices, getPersistedActiveTeam, getWrapperSessionMapping, setActiveTeamSession, setPersistedActiveTeam, setWrapperSessionMapping, } from './service-factory.js';
6
+ import { getOrCreatePluginServices, getParentSessionId, getSessionTeam, getWrapperSessionMapping, registerParentSession, registerSessionTeam, setWrapperSessionMapping, } from './service-factory.js';
7
7
  const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
8
8
  const MODE_ENUM = ['explore', 'implement', 'verify'];
9
- export const ClaudeManagerPlugin = async ({ worktree }) => {
9
+ export const ClaudeManagerPlugin = async ({ worktree, client }) => {
10
10
  const services = getOrCreatePluginServices(worktree);
11
11
  await services.approvalManager.loadPersistedPolicy();
12
+ /**
13
+ * Resolves the team ID for a brand-new engineer wrapper session.
14
+ *
15
+ * 1. Walk the cached parentID chain (populated by session.created events).
16
+ * 2. On a cache miss, attempt a live client.session.get() lookup and cache
17
+ * whatever parentID the SDK returns.
18
+ * 3. Fall back to the orphan sentinel (sessionID itself) only when both
19
+ * cache and live lookup come up empty.
20
+ */
21
+ async function resolveTeamId(sessionID) {
22
+ let current = sessionID;
23
+ const seen = new Set();
24
+ while (current && !seen.has(current)) {
25
+ seen.add(current);
26
+ const team = getSessionTeam(current);
27
+ if (team !== undefined)
28
+ return team;
29
+ // Cache miss on this node's parent: try the live SDK.
30
+ if (client && !getParentSessionId(current)) {
31
+ try {
32
+ const result = await client.session.get({ path: { id: current } });
33
+ const parentID = result.data?.parentID;
34
+ if (parentID) {
35
+ registerParentSession(current, parentID);
36
+ }
37
+ }
38
+ catch {
39
+ // Network / auth failure — let the walk continue to orphan.
40
+ }
41
+ }
42
+ current = getParentSessionId(current);
43
+ }
44
+ return sessionID;
45
+ }
12
46
  return {
13
47
  config: async (config) => {
14
48
  config.agent ??= {};
@@ -23,15 +57,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
23
57
  },
24
58
  'chat.message': async (input) => {
25
59
  if (input.agent === AGENT_CTO) {
26
- // Adopt the persisted active team if one exists, so a new CTO session
27
- // does not orphan previously created engineers and wrapper memory.
28
- const persistedTeamId = await getPersistedActiveTeam(worktree);
29
- const activeTeamId = persistedTeamId ?? input.sessionID;
30
- setActiveTeamSession(worktree, activeTeamId);
31
- if (!persistedTeamId) {
32
- // First CTO session for this worktree — persist this session as active team.
33
- await setPersistedActiveTeam(worktree, activeTeamId);
34
- }
60
+ // Each CTO session ID is its own team. The session ID is the durable
61
+ // identity: no worktree-global active-team state.
62
+ registerSessionTeam(input.sessionID, input.sessionID);
35
63
  return;
36
64
  }
37
65
  if (input.agent && isEngineerAgent(input.agent)) {
@@ -39,7 +67,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
39
67
  const existing = getWrapperSessionMapping(worktree, input.sessionID);
40
68
  const persisted = existing ??
41
69
  (await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
42
- const teamId = persisted?.teamId ?? (await resolveTeamId(worktree, input.sessionID));
70
+ const teamId = persisted?.teamId ?? (await resolveTeamId(input.sessionID));
43
71
  setWrapperSessionMapping(worktree, input.sessionID, {
44
72
  teamId,
45
73
  workerName: engineer,
@@ -47,6 +75,14 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
47
75
  await services.orchestrator.recordWrapperSession(worktree, teamId, engineer, input.sessionID);
48
76
  }
49
77
  },
78
+ event: async ({ event: sdkEvent }) => {
79
+ if (sdkEvent.type === 'session.created') {
80
+ const session = sdkEvent.properties.info;
81
+ if (session.parentID) {
82
+ registerParentSession(session.id, session.parentID);
83
+ }
84
+ }
85
+ },
50
86
  'experimental.chat.system.transform': async (input, output) => {
51
87
  if (!input.sessionID) {
52
88
  return;
@@ -86,7 +122,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
86
122
  const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
87
123
  const persisted = existing ??
88
124
  (await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
89
- const teamId = persisted?.teamId ?? (await resolveTeamId(context.worktree, context.sessionID));
125
+ const teamId = persisted?.teamId ?? (await resolveTeamId(context.sessionID));
90
126
  setWrapperSessionMapping(context.worktree, context.sessionID, {
91
127
  teamId,
92
128
  workerName: engineer,
@@ -117,7 +153,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
117
153
  teamId: tool.schema.string().optional(),
118
154
  },
119
155
  async execute(args, context) {
120
- const teamId = args.teamId ?? getActiveTeamSession(context.worktree) ?? context.sessionID;
156
+ const teamId = args.teamId ?? context.sessionID;
121
157
  annotateToolRun(context, 'Reading team status', {
122
158
  teamId,
123
159
  });
@@ -134,7 +170,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
134
170
  model: tool.schema.enum(MODEL_ENUM).optional(),
135
171
  },
136
172
  async execute(args, context) {
137
- const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
173
+ const teamId = context.sessionID;
138
174
  // Pre-determine engineers for event labeling (using orchestrator selection logic)
139
175
  const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
140
176
  annotateToolRun(context, 'Running dual-engineer plan synthesis', {
@@ -178,7 +214,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
178
214
  clearHistory: tool.schema.boolean().optional(),
179
215
  },
180
216
  async execute(args, context) {
181
- const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
217
+ const teamId = context.sessionID;
182
218
  annotateToolRun(context, `Resetting ${args.engineer}`, {
183
219
  teamId,
184
220
  clearSession: args.clearSession,
@@ -456,14 +492,6 @@ export function isEngineerAgent(agentId) {
456
492
  const normalized = normalizeAgentId(agentId);
457
493
  return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
458
494
  }
459
- /**
460
- * Resolves the team ID for an engineer session.
461
- * Reads the persisted active team first (survives process restarts), then
462
- * falls back to the in-memory registry, then to the raw session ID as a last resort.
463
- */
464
- async function resolveTeamId(worktree, sessionID) {
465
- return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
466
- }
467
495
  function formatToolDescription(toolName, toolArgs) {
468
496
  if (!toolArgs || typeof toolArgs !== 'object')
469
497
  return undefined;
@@ -14,10 +14,10 @@ interface ClaudeManagerPluginServices {
14
14
  }
15
15
  export declare function getOrCreatePluginServices(worktree: string): ClaudeManagerPluginServices;
16
16
  export declare function clearPluginServices(): void;
17
- export declare function setActiveTeamSession(worktree: string, teamId: string): void;
18
- export declare function getActiveTeamSession(worktree: string): string | null;
19
- export declare function getPersistedActiveTeam(worktree: string): Promise<string | null>;
20
- export declare function setPersistedActiveTeam(worktree: string, teamId: string): Promise<void>;
17
+ export declare function registerParentSession(childId: string, parentId: string): void;
18
+ export declare function getParentSessionId(childId: string): string | undefined;
19
+ export declare function registerSessionTeam(sessionId: string, teamId: string): void;
20
+ export declare function getSessionTeam(sessionId: string): string | undefined;
21
21
  export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
22
22
  teamId: string;
23
23
  workerName: EngineerName;
@@ -10,8 +10,11 @@ import { TeamStateStore } from '../state/team-state-store.js';
10
10
  import { TranscriptStore } from '../state/transcript-store.js';
11
11
  import { buildWorkerCapabilities } from './agents/browser-qa.js';
12
12
  const serviceRegistry = new Map();
13
- const activeTeamRegistry = new Map();
14
13
  const wrapperSessionRegistry = new Map();
14
+ /** childSessionId → parentSessionId — populated from session.created events */
15
+ const parentSessionRegistry = new Map();
16
+ /** ctoSessionId → teamId — populated when a CTO chat.message fires */
17
+ const sessionTeamRegistry = new Map();
15
18
  export function getOrCreatePluginServices(worktree) {
16
19
  const existing = serviceRegistry.get(worktree);
17
20
  if (existing) {
@@ -40,28 +43,21 @@ export function getOrCreatePluginServices(worktree) {
40
43
  }
41
44
  export function clearPluginServices() {
42
45
  serviceRegistry.clear();
43
- activeTeamRegistry.clear();
44
46
  wrapperSessionRegistry.clear();
47
+ parentSessionRegistry.clear();
48
+ sessionTeamRegistry.clear();
45
49
  }
46
- export function setActiveTeamSession(worktree, teamId) {
47
- activeTeamRegistry.set(worktree, teamId);
50
+ export function registerParentSession(childId, parentId) {
51
+ parentSessionRegistry.set(childId, parentId);
48
52
  }
49
- export function getActiveTeamSession(worktree) {
50
- return activeTeamRegistry.get(worktree) ?? null;
53
+ export function getParentSessionId(childId) {
54
+ return parentSessionRegistry.get(childId);
51
55
  }
52
- export async function getPersistedActiveTeam(worktree) {
53
- const services = serviceRegistry.get(worktree);
54
- if (!services) {
55
- return null;
56
- }
57
- return services.teamStore.getActiveTeam(worktree);
56
+ export function registerSessionTeam(sessionId, teamId) {
57
+ sessionTeamRegistry.set(sessionId, teamId);
58
58
  }
59
- export async function setPersistedActiveTeam(worktree, teamId) {
60
- const services = serviceRegistry.get(worktree);
61
- if (!services) {
62
- return;
63
- }
64
- await services.teamStore.setActiveTeam(worktree, teamId);
59
+ export function getSessionTeam(sessionId) {
60
+ return sessionTeamRegistry.get(sessionId);
65
61
  }
66
62
  export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
67
63
  wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
@@ -3,12 +3,46 @@ import { managerPromptRegistry } from '../prompts/registry.js';
3
3
  import { isEngineerName } from '../team/roster.js';
4
4
  import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
5
5
  import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, buildBrowserQaAgentConfig, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from './agents/index.js';
6
- import { getActiveTeamSession, getOrCreatePluginServices, getPersistedActiveTeam, getWrapperSessionMapping, setActiveTeamSession, setPersistedActiveTeam, setWrapperSessionMapping, } from './service-factory.js';
6
+ import { getOrCreatePluginServices, getParentSessionId, getSessionTeam, getWrapperSessionMapping, registerParentSession, registerSessionTeam, setWrapperSessionMapping, } from './service-factory.js';
7
7
  const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
8
8
  const MODE_ENUM = ['explore', 'implement', 'verify'];
9
- export const ClaudeManagerPlugin = async ({ worktree }) => {
9
+ export const ClaudeManagerPlugin = async ({ worktree, client }) => {
10
10
  const services = getOrCreatePluginServices(worktree);
11
11
  await services.approvalManager.loadPersistedPolicy();
12
+ /**
13
+ * Resolves the team ID for a brand-new engineer wrapper session.
14
+ *
15
+ * 1. Walk the cached parentID chain (populated by session.created events).
16
+ * 2. On a cache miss, attempt a live client.session.get() lookup and cache
17
+ * whatever parentID the SDK returns.
18
+ * 3. Fall back to the orphan sentinel (sessionID itself) only when both
19
+ * cache and live lookup come up empty.
20
+ */
21
+ async function resolveTeamId(sessionID) {
22
+ let current = sessionID;
23
+ const seen = new Set();
24
+ while (current && !seen.has(current)) {
25
+ seen.add(current);
26
+ const team = getSessionTeam(current);
27
+ if (team !== undefined)
28
+ return team;
29
+ // Cache miss on this node's parent: try the live SDK.
30
+ if (client && !getParentSessionId(current)) {
31
+ try {
32
+ const result = await client.session.get({ path: { id: current } });
33
+ const parentID = result.data?.parentID;
34
+ if (parentID) {
35
+ registerParentSession(current, parentID);
36
+ }
37
+ }
38
+ catch {
39
+ // Network / auth failure — let the walk continue to orphan.
40
+ }
41
+ }
42
+ current = getParentSessionId(current);
43
+ }
44
+ return sessionID;
45
+ }
12
46
  return {
13
47
  config: async (config) => {
14
48
  config.agent ??= {};
@@ -23,15 +57,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
23
57
  },
24
58
  'chat.message': async (input) => {
25
59
  if (input.agent === AGENT_CTO) {
26
- // Adopt the persisted active team if one exists, so a new CTO session
27
- // does not orphan previously created engineers and wrapper memory.
28
- const persistedTeamId = await getPersistedActiveTeam(worktree);
29
- const activeTeamId = persistedTeamId ?? input.sessionID;
30
- setActiveTeamSession(worktree, activeTeamId);
31
- if (!persistedTeamId) {
32
- // First CTO session for this worktree — persist this session as active team.
33
- await setPersistedActiveTeam(worktree, activeTeamId);
34
- }
60
+ // Each CTO session ID is its own team. The session ID is the durable
61
+ // identity: no worktree-global active-team state.
62
+ registerSessionTeam(input.sessionID, input.sessionID);
35
63
  return;
36
64
  }
37
65
  if (input.agent && isEngineerAgent(input.agent)) {
@@ -39,7 +67,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
39
67
  const existing = getWrapperSessionMapping(worktree, input.sessionID);
40
68
  const persisted = existing ??
41
69
  (await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
42
- const teamId = persisted?.teamId ?? (await resolveTeamId(worktree, input.sessionID));
70
+ const teamId = persisted?.teamId ?? (await resolveTeamId(input.sessionID));
43
71
  setWrapperSessionMapping(worktree, input.sessionID, {
44
72
  teamId,
45
73
  workerName: engineer,
@@ -47,6 +75,14 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
47
75
  await services.orchestrator.recordWrapperSession(worktree, teamId, engineer, input.sessionID);
48
76
  }
49
77
  },
78
+ event: async ({ event: sdkEvent }) => {
79
+ if (sdkEvent.type === 'session.created') {
80
+ const session = sdkEvent.properties.info;
81
+ if (session.parentID) {
82
+ registerParentSession(session.id, session.parentID);
83
+ }
84
+ }
85
+ },
50
86
  'experimental.chat.system.transform': async (input, output) => {
51
87
  if (!input.sessionID) {
52
88
  return;
@@ -86,7 +122,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
86
122
  const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
87
123
  const persisted = existing ??
88
124
  (await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
89
- const teamId = persisted?.teamId ?? (await resolveTeamId(context.worktree, context.sessionID));
125
+ const teamId = persisted?.teamId ?? (await resolveTeamId(context.sessionID));
90
126
  setWrapperSessionMapping(context.worktree, context.sessionID, {
91
127
  teamId,
92
128
  workerName: engineer,
@@ -117,7 +153,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
117
153
  teamId: tool.schema.string().optional(),
118
154
  },
119
155
  async execute(args, context) {
120
- const teamId = args.teamId ?? getActiveTeamSession(context.worktree) ?? context.sessionID;
156
+ const teamId = args.teamId ?? context.sessionID;
121
157
  annotateToolRun(context, 'Reading team status', {
122
158
  teamId,
123
159
  });
@@ -134,7 +170,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
134
170
  model: tool.schema.enum(MODEL_ENUM).optional(),
135
171
  },
136
172
  async execute(args, context) {
137
- const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
173
+ const teamId = context.sessionID;
138
174
  // Pre-determine engineers for event labeling (using orchestrator selection logic)
139
175
  const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
140
176
  annotateToolRun(context, 'Running dual-engineer plan synthesis', {
@@ -178,7 +214,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
178
214
  clearHistory: tool.schema.boolean().optional(),
179
215
  },
180
216
  async execute(args, context) {
181
- const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
217
+ const teamId = context.sessionID;
182
218
  annotateToolRun(context, `Resetting ${args.engineer}`, {
183
219
  teamId,
184
220
  clearSession: args.clearSession,
@@ -456,14 +492,6 @@ export function isEngineerAgent(agentId) {
456
492
  const normalized = normalizeAgentId(agentId);
457
493
  return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
458
494
  }
459
- /**
460
- * Resolves the team ID for an engineer session.
461
- * Reads the persisted active team first (survives process restarts), then
462
- * falls back to the in-memory registry, then to the raw session ID as a last resort.
463
- */
464
- async function resolveTeamId(worktree, sessionID) {
465
- return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
466
- }
467
495
  function formatToolDescription(toolName, toolArgs) {
468
496
  if (!toolArgs || typeof toolArgs !== 'object')
469
497
  return undefined;
@@ -14,10 +14,10 @@ interface ClaudeManagerPluginServices {
14
14
  }
15
15
  export declare function getOrCreatePluginServices(worktree: string): ClaudeManagerPluginServices;
16
16
  export declare function clearPluginServices(): void;
17
- export declare function setActiveTeamSession(worktree: string, teamId: string): void;
18
- export declare function getActiveTeamSession(worktree: string): string | null;
19
- export declare function getPersistedActiveTeam(worktree: string): Promise<string | null>;
20
- export declare function setPersistedActiveTeam(worktree: string, teamId: string): Promise<void>;
17
+ export declare function registerParentSession(childId: string, parentId: string): void;
18
+ export declare function getParentSessionId(childId: string): string | undefined;
19
+ export declare function registerSessionTeam(sessionId: string, teamId: string): void;
20
+ export declare function getSessionTeam(sessionId: string): string | undefined;
21
21
  export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
22
22
  teamId: string;
23
23
  workerName: EngineerName;
@@ -10,8 +10,11 @@ import { TeamStateStore } from '../state/team-state-store.js';
10
10
  import { TranscriptStore } from '../state/transcript-store.js';
11
11
  import { buildWorkerCapabilities } from './agents/browser-qa.js';
12
12
  const serviceRegistry = new Map();
13
- const activeTeamRegistry = new Map();
14
13
  const wrapperSessionRegistry = new Map();
14
+ /** childSessionId → parentSessionId — populated from session.created events */
15
+ const parentSessionRegistry = new Map();
16
+ /** ctoSessionId → teamId — populated when a CTO chat.message fires */
17
+ const sessionTeamRegistry = new Map();
15
18
  export function getOrCreatePluginServices(worktree) {
16
19
  const existing = serviceRegistry.get(worktree);
17
20
  if (existing) {
@@ -40,28 +43,21 @@ export function getOrCreatePluginServices(worktree) {
40
43
  }
41
44
  export function clearPluginServices() {
42
45
  serviceRegistry.clear();
43
- activeTeamRegistry.clear();
44
46
  wrapperSessionRegistry.clear();
47
+ parentSessionRegistry.clear();
48
+ sessionTeamRegistry.clear();
45
49
  }
46
- export function setActiveTeamSession(worktree, teamId) {
47
- activeTeamRegistry.set(worktree, teamId);
50
+ export function registerParentSession(childId, parentId) {
51
+ parentSessionRegistry.set(childId, parentId);
48
52
  }
49
- export function getActiveTeamSession(worktree) {
50
- return activeTeamRegistry.get(worktree) ?? null;
53
+ export function getParentSessionId(childId) {
54
+ return parentSessionRegistry.get(childId);
51
55
  }
52
- export async function getPersistedActiveTeam(worktree) {
53
- const services = serviceRegistry.get(worktree);
54
- if (!services) {
55
- return null;
56
- }
57
- return services.teamStore.getActiveTeam(worktree);
56
+ export function registerSessionTeam(sessionId, teamId) {
57
+ sessionTeamRegistry.set(sessionId, teamId);
58
58
  }
59
- export async function setPersistedActiveTeam(worktree, teamId) {
60
- const services = serviceRegistry.get(worktree);
61
- if (!services) {
62
- return;
63
- }
64
- await services.teamStore.setActiveTeam(worktree, teamId);
59
+ export function getSessionTeam(sessionId) {
60
+ return sessionTeamRegistry.get(sessionId);
65
61
  }
66
62
  export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
67
63
  wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
@@ -7,10 +7,7 @@ export declare class TeamStateStore {
7
7
  getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
8
8
  listTeams(cwd: string): Promise<TeamRecord[]>;
9
9
  updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
10
- getActiveTeam(cwd: string): Promise<string | null>;
11
- setActiveTeam(cwd: string, teamId: string): Promise<void>;
12
10
  private getTeamKey;
13
- private getActiveTeamPath;
14
11
  private getTeamsDirectory;
15
12
  private getTeamPath;
16
13
  private enqueueWrite;
@@ -62,31 +62,9 @@ export class TeamStateStore {
62
62
  return updated;
63
63
  });
64
64
  }
65
- async getActiveTeam(cwd) {
66
- const filePath = this.getActiveTeamPath(cwd);
67
- try {
68
- const content = await fs.readFile(filePath, 'utf8');
69
- const parsed = JSON.parse(content);
70
- return parsed.teamId ?? null;
71
- }
72
- catch (error) {
73
- if (isFileNotFoundError(error)) {
74
- return null;
75
- }
76
- throw error;
77
- }
78
- }
79
- async setActiveTeam(cwd, teamId) {
80
- const filePath = this.getActiveTeamPath(cwd);
81
- await fs.mkdir(path.dirname(filePath), { recursive: true });
82
- await writeJsonAtomically(filePath, { teamId });
83
- }
84
65
  getTeamKey(cwd, teamId) {
85
66
  return `${cwd}:${teamId}`;
86
67
  }
87
- getActiveTeamPath(cwd) {
88
- return path.join(cwd, this.baseDirectoryName, 'active-team.json');
89
- }
90
68
  getTeamsDirectory(cwd) {
91
69
  return path.join(cwd, this.baseDirectoryName, 'teams');
92
70
  }
@@ -7,10 +7,7 @@ export declare class TeamStateStore {
7
7
  getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
8
8
  listTeams(cwd: string): Promise<TeamRecord[]>;
9
9
  updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
10
- getActiveTeam(cwd: string): Promise<string | null>;
11
- setActiveTeam(cwd: string, teamId: string): Promise<void>;
12
10
  private getTeamKey;
13
- private getActiveTeamPath;
14
11
  private getTeamsDirectory;
15
12
  private getTeamPath;
16
13
  private enqueueWrite;
@@ -62,31 +62,9 @@ export class TeamStateStore {
62
62
  return updated;
63
63
  });
64
64
  }
65
- async getActiveTeam(cwd) {
66
- const filePath = this.getActiveTeamPath(cwd);
67
- try {
68
- const content = await fs.readFile(filePath, 'utf8');
69
- const parsed = JSON.parse(content);
70
- return parsed.teamId ?? null;
71
- }
72
- catch (error) {
73
- if (isFileNotFoundError(error)) {
74
- return null;
75
- }
76
- throw error;
77
- }
78
- }
79
- async setActiveTeam(cwd, teamId) {
80
- const filePath = this.getActiveTeamPath(cwd);
81
- await fs.mkdir(path.dirname(filePath), { recursive: true });
82
- await writeJsonAtomically(filePath, { teamId });
83
- }
84
65
  getTeamKey(cwd, teamId) {
85
66
  return `${cwd}:${teamId}`;
86
67
  }
87
- getActiveTeamPath(cwd) {
88
- return path.join(cwd, this.baseDirectoryName, 'active-team.json');
89
- }
90
68
  getTeamsDirectory(cwd) {
91
69
  return path.join(cwd, this.baseDirectoryName, 'teams');
92
70
  }
@@ -1,12 +1,19 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1
+ /**
2
+ * Tests for the session-per-team CTO model:
3
+ * - Each CTO session ID is its own team ID (no shared repo-global state).
4
+ * - Same CTO session ID recovers the same team context across restarts.
5
+ * - A different CTO session ID in the same worktree creates an independent team.
6
+ * - Engineers spawned within a CTO session resolve to that CTO's team.
7
+ * - CTO task permissions do not allow self-delegation.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
10
  import { mkdtemp, rm } from 'node:fs/promises';
3
11
  import { join } from 'node:path';
4
12
  import { tmpdir } from 'node:os';
5
13
  import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
6
- import { clearPluginServices, getActiveTeamSession } from '../src/plugin/service-factory.js';
7
- import { AGENT_CTO } from '../src/plugin/agent-hierarchy.js';
8
- import { TeamStateStore } from '../src/state/team-state-store.js';
9
- describe('CTO chat.message — persisted active team', () => {
14
+ import { clearPluginServices, getOrCreatePluginServices, getSessionTeam, getWrapperSessionMapping, registerParentSession, } from '../src/plugin/service-factory.js';
15
+ import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agents/index.js';
16
+ describe('CTO chat.message session-per-team model', () => {
10
17
  let tempRoot;
11
18
  beforeEach(async () => {
12
19
  tempRoot = await mkdtemp(join(tmpdir(), 'cto-team-'));
@@ -18,35 +25,175 @@ describe('CTO chat.message — persisted active team', () => {
18
25
  await rm(tempRoot, { recursive: true, force: true });
19
26
  }
20
27
  });
21
- it('persists the active team on the first CTO message', async () => {
28
+ it('CTO session ID is used directly as the team ID', async () => {
22
29
  const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
23
30
  const chatMessage = plugin['chat.message'];
24
- await chatMessage({ agent: AGENT_CTO, sessionID: 'session-cto-1' });
25
- const store = new TeamStateStore('.claude-manager');
26
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('session-cto-1');
27
- expect(getActiveTeamSession(tempRoot)).toBe('session-cto-1');
28
- });
29
- it('a new CTO session adopts the already-persisted active team instead of overwriting it', async () => {
30
- const store = new TeamStateStore('.claude-manager');
31
- // Simulate a pre-existing persisted active team (e.g., from a previous process run).
32
- await store.setActiveTeam(tempRoot, 'old-team-id');
31
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
32
+ expect(getSessionTeam('cto-session-1')).toBe('cto-session-1');
33
+ });
34
+ it('same CTO session ID recovers its team context after a process restart', async () => {
35
+ // Phase 1: CTO session 'cto-1' runs and a team record is created on disk.
36
+ const plugin1 = await ClaudeManagerPlugin({ worktree: tempRoot });
37
+ const chatMessage1 = plugin1['chat.message'];
38
+ await chatMessage1({ agent: AGENT_CTO, sessionID: 'cto-1' });
39
+ const services1 = getOrCreatePluginServices(tempRoot);
40
+ await services1.orchestrator.getOrCreateTeam(tempRoot, 'cto-1'); // persist team to disk
41
+ expect(getSessionTeam('cto-1')).toBe('cto-1');
42
+ // Phase 2: Simulate a process restart (all in-memory state is lost).
43
+ clearPluginServices();
44
+ // Phase 3: The same CTO session ID resumes — it should re-register itself.
45
+ const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
46
+ const chatMessage2 = plugin2['chat.message'];
47
+ await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
48
+ expect(getSessionTeam('cto-1')).toBe('cto-1');
49
+ // The persisted team record from Phase 1 should still be accessible.
50
+ const services2 = getOrCreatePluginServices(tempRoot);
51
+ const team = await services2.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
52
+ expect(team.id).toBe('cto-1');
53
+ });
54
+ it('a different CTO session ID creates an independent team, not adopting prior state', async () => {
55
+ // Phase 1: CTO session 'cto-1' runs.
56
+ const plugin1 = await ClaudeManagerPlugin({ worktree: tempRoot });
57
+ const chatMessage1 = plugin1['chat.message'];
58
+ await chatMessage1({ agent: AGENT_CTO, sessionID: 'cto-1' });
59
+ const services1 = getOrCreatePluginServices(tempRoot);
60
+ await services1.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
61
+ // Phase 2: Process restart.
62
+ clearPluginServices();
63
+ // Phase 3: A brand-new CTO session 'cto-2' starts.
64
+ const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
65
+ const chatMessage2 = plugin2['chat.message'];
66
+ await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
67
+ // Must use its OWN session ID as team — must NOT adopt 'cto-1'.
68
+ expect(getSessionTeam('cto-2')).toBe('cto-2');
69
+ expect(getSessionTeam('cto-1')).toBeUndefined(); // cleared by restart
70
+ // 'cto-1' team data remains on disk, untouched.
71
+ const services2 = getOrCreatePluginServices(tempRoot);
72
+ const team1 = await services2.teamStore.getTeam(tempRoot, 'cto-1');
73
+ expect(team1).not.toBeNull(); // still present
74
+ expect(team1.id).toBe('cto-1');
75
+ });
76
+ it('multiple chat.message calls from the same CTO session do not change the active team', async () => {
33
77
  const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
34
78
  const chatMessage = plugin['chat.message'];
35
- // New CTO session with a different session ID.
36
- await chatMessage({ agent: AGENT_CTO, sessionID: 'brand-new-cto-session' });
37
- // The persisted active team must NOT be overwritten.
38
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('old-team-id');
39
- // The in-memory registry must point to the persisted team, NOT the new session.
40
- expect(getActiveTeamSession(tempRoot)).toBe('old-team-id');
41
- });
42
- it('does not overwrite the persisted team across two CTO messages in the same session', async () => {
43
- const store = new TeamStateStore('.claude-manager');
79
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
80
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
81
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
82
+ expect(getSessionTeam('cto-session-1')).toBe('cto-session-1');
83
+ });
84
+ it("engineers spawned during a CTO session resolve to that CTO session's team", async () => {
85
+ const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
86
+ const chatMessage = plugin['chat.message'];
87
+ // CTO session fires first, establishing the active team.
88
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-A' });
89
+ // Simulate session.created event: OpenCode fires this before chat.message for the engineer.
90
+ registerParentSession('wrapper-tom-1', 'cto-A');
91
+ // Engineer wrapper session fires (spawned by the CTO).
92
+ await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-1' });
93
+ // The wrapper session must be mapped to the CTO's team, not a new orphan team.
94
+ const mapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-1');
95
+ expect(mapping).toBeDefined();
96
+ expect(mapping.teamId).toBe('cto-A');
97
+ expect(mapping.workerName).toBe('Tom');
98
+ });
99
+ it('two concurrent CTO sessions each bind their own engineers independently', async () => {
100
+ const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
101
+ const chatMessage = plugin['chat.message'];
102
+ // Two CTO sessions start concurrently in the same worktree.
103
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-alpha' });
104
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-beta' });
105
+ // Simulate session.created events: each CTO spawns one engineer sub-session.
106
+ // The event hook normally calls registerParentSession; call it directly here.
107
+ registerParentSession('wrapper-tom-alpha', 'cto-alpha');
108
+ registerParentSession('wrapper-sara-beta', 'cto-beta');
109
+ // Engineer wrapper sessions check in.
110
+ await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-alpha' });
111
+ await chatMessage({ agent: ENGINEER_AGENT_IDS.Sara, sessionID: 'wrapper-sara-beta' });
112
+ // Tom must bind to cto-alpha's team, not cto-beta's.
113
+ const tomMapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-alpha');
114
+ expect(tomMapping).toBeDefined();
115
+ expect(tomMapping.teamId).toBe('cto-alpha');
116
+ // Sara must bind to cto-beta's team, not cto-alpha's.
117
+ const saraMapping = getWrapperSessionMapping(tempRoot, 'wrapper-sara-beta');
118
+ expect(saraMapping).toBeDefined();
119
+ expect(saraMapping.teamId).toBe('cto-beta');
120
+ });
121
+ });
122
+ describe('CTO task permissions — self-delegation', () => {
123
+ let tempRoot;
124
+ beforeEach(async () => {
125
+ tempRoot = await mkdtemp(join(tmpdir(), 'cto-perms-'));
126
+ clearPluginServices();
127
+ });
128
+ afterEach(async () => {
129
+ clearPluginServices();
130
+ if (tempRoot)
131
+ await rm(tempRoot, { recursive: true, force: true });
132
+ });
133
+ it('CTO task permissions deny delegation to cto', async () => {
44
134
  const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
135
+ const config = {};
136
+ await plugin.config?.(config);
137
+ const agents = (config.agent ?? {});
138
+ const cto = agents[AGENT_CTO];
139
+ const taskPerms = cto.permission.task;
140
+ // Default deny must apply, and cto must not be explicitly allowed.
141
+ expect(taskPerms['*']).toBe('deny');
142
+ expect(taskPerms['cto']).toBeUndefined();
143
+ expect(taskPerms['CTO']).toBeUndefined();
144
+ });
145
+ });
146
+ describe('CTO tool isolation — concurrent sessions', () => {
147
+ let tempRoot;
148
+ beforeEach(async () => {
149
+ tempRoot = await mkdtemp(join(tmpdir(), 'cto-isolation-'));
150
+ clearPluginServices();
151
+ });
152
+ afterEach(async () => {
153
+ clearPluginServices();
154
+ if (tempRoot)
155
+ await rm(tempRoot, { recursive: true, force: true });
156
+ });
157
+ it('team_status called from CTO-A uses CTO-A team even after CTO-B has chatted', async () => {
158
+ const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
159
+ const chatMessage = plugin['chat.message'];
160
+ // CTO-A registers first, then CTO-B registers (simulating two concurrent sessions).
161
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-A' });
162
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-B' });
163
+ // Execute team_status as CTO-A (sessionID = 'cto-A').
164
+ const teamStatusTool = plugin.tool['team_status'];
165
+ const ctx = {
166
+ metadata: vi.fn(),
167
+ worktree: tempRoot,
168
+ sessionID: 'cto-A',
169
+ agent: AGENT_CTO,
170
+ abort: new AbortController().signal,
171
+ };
172
+ const result = JSON.parse(await teamStatusTool.execute({}, ctx));
173
+ // Must load cto-A's team, NOT cto-B's (last-write-wins global would give 'cto-B').
174
+ expect(result.id).toBe('cto-A');
175
+ });
176
+ it('resolves engineer team via live SDK lookup when session.created was not received', async () => {
177
+ // Simulate a client whose session.get returns parentID for the engineer session.
178
+ const mockClient = {
179
+ session: {
180
+ get: vi.fn().mockImplementation(async ({ path }) => {
181
+ if (path.id === 'wrapper-tom-live') {
182
+ return { data: { id: 'wrapper-tom-live', parentID: 'cto-live' } };
183
+ }
184
+ return { data: undefined };
185
+ }),
186
+ },
187
+ };
188
+ const plugin = await ClaudeManagerPlugin({ worktree: tempRoot, client: mockClient });
45
189
  const chatMessage = plugin['chat.message'];
46
- await chatMessage({ agent: AGENT_CTO, sessionID: 'session-cto-1' });
47
- await chatMessage({ agent: AGENT_CTO, sessionID: 'session-cto-1' });
48
- // Still the original session persisted.
49
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('session-cto-1');
50
- expect(getActiveTeamSession(tempRoot)).toBe('session-cto-1');
190
+ // CTO registers its team via chat.message.
191
+ await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-live' });
192
+ // No registerParentSession call — simulates session.created arriving late or being missed.
193
+ // Engineer wrapper fires; resolveTeamId must fall through to live SDK lookup.
194
+ await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-live' });
195
+ const mapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-live');
196
+ expect(mapping?.teamId).toBe('cto-live');
197
+ expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: 'wrapper-tom-live' } });
51
198
  });
52
199
  });
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
11
- import { clearPluginServices, getActiveTeamSession, getOrCreatePluginServices, } from '../src/plugin/service-factory.js';
11
+ import { clearPluginServices, getOrCreatePluginServices, getSessionTeam, registerParentSession, } from '../src/plugin/service-factory.js';
12
12
  import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agent-hierarchy.js';
13
13
  import { TeamStateStore } from '../src/state/team-state-store.js';
14
14
  import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
@@ -197,23 +197,25 @@ describe('second invocation continuity', () => {
197
197
  if (tempRoot)
198
198
  await rm(tempRoot, { recursive: true, force: true });
199
199
  });
200
- it('wrapper memory is injected after clearPluginServices and a new plugin instance', async () => {
200
+ it('wrapper memory is injected after clearPluginServices and same CTO session resumes', async () => {
201
201
  // ── Phase 1: first task via orchestrator (no real SDK needed) ──────────
202
+ // Team ID = 'cto-1' (the CTO session ID that originally ran this work).
202
203
  const store = new TeamStateStore();
203
- await store.setActiveTeam(tempRoot, 'cto-1');
204
204
  const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
205
205
  await orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1');
206
206
  await orchestrator.recordWrapperExchange(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1', 'explore', 'Investigate the auth flow', 'Found two race conditions in the token refresh path.');
207
207
  // ── Phase 2: process restart ───────────────────────────────────────────
208
208
  clearPluginServices();
209
- // ── Phase 3: new plugin instance, new CTO session ──────────────────────
209
+ // ── Phase 3: same CTO session resumes, engineers run a new wrapper ──────
210
210
  const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
211
211
  const chatMessage2 = plugin2['chat.message'];
212
212
  const systemTransform2 = plugin2['experimental.chat.system.transform'];
213
- // New CTO session must adopt the persisted team, not create a new one.
214
- await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
215
- expect(getActiveTeamSession(tempRoot)).toBe('cto-1');
216
- // Tom's new wrapper session must be registered under the persisted team.
213
+ // Same CTO session ID resumes re-registers itself as the team.
214
+ await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
215
+ expect(getSessionTeam('cto-1')).toBe('cto-1');
216
+ // Simulate session.created: OpenCode fires this when cto-1 spawns the new wrapper.
217
+ registerParentSession('wrapper-tom-2', 'cto-1');
218
+ // Tom's new wrapper session is registered under the same team.
217
219
  await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
218
220
  // Transform fires (after chat.message has registered the session mapping).
219
221
  const output = { system: [] };
@@ -224,9 +226,8 @@ describe('second invocation continuity', () => {
224
226
  expect(output.system[0]).toContain('Found two race conditions');
225
227
  });
226
228
  it('existing engineer Claude session is resumed on second invocation', async () => {
227
- // ── Phase 1: pre-seed Tom with a claudeSessionId ───────────────────────
229
+ // ── Phase 1: pre-seed Tom with a claudeSessionId under team 'cto-1' ────
228
230
  const store = new TeamStateStore();
229
- await store.setActiveTeam(tempRoot, 'cto-1');
230
231
  const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
231
232
  await orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
232
233
  await store.updateTeam(tempRoot, 'cto-1', (team) => ({
@@ -235,10 +236,13 @@ describe('second invocation continuity', () => {
235
236
  }));
236
237
  // ── Phase 2: process restart ───────────────────────────────────────────
237
238
  clearPluginServices();
238
- // ── Phase 3: new plugin, new CTO, engineer runs second task ───────────
239
+ // ── Phase 3: same CTO session resumes, engineer runs second task ───────
239
240
  const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
240
241
  const chatMessage2 = plugin2['chat.message'];
241
- await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
242
+ // Same CTO session ID — re-registers as the team so Tom can find his session.
243
+ await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
244
+ // Simulate session.created: OpenCode fires this when cto-1 spawns the new wrapper.
245
+ registerParentSession('wrapper-tom-2', 'cto-1');
242
246
  await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
243
247
  const services2 = getOrCreatePluginServices(tempRoot);
244
248
  // Mock at the session level so dispatchEngineer runs its real logic
@@ -51,22 +51,4 @@ describe('TeamStateStore', () => {
51
51
  { id: 'older' },
52
52
  ]);
53
53
  });
54
- it('returns null for active team when no active-team.json exists', async () => {
55
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
56
- const store = new TeamStateStore('.state');
57
- await expect(store.getActiveTeam(tempRoot)).resolves.toBeNull();
58
- });
59
- it('persists and reads back the active team ID', async () => {
60
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
61
- const store = new TeamStateStore('.state');
62
- await store.setActiveTeam(tempRoot, 'team-abc');
63
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-abc');
64
- });
65
- it('overwrites the active team ID on subsequent writes', async () => {
66
- tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
67
- const store = new TeamStateStore('.state');
68
- await store.setActiveTeam(tempRoot, 'team-first');
69
- await store.setActiveTeam(tempRoot, 'team-second');
70
- await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-second');
71
- });
72
54
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",