@doingdev/opencode-claude-manager-plugin 0.1.34 → 0.1.35

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 CHANGED
@@ -53,15 +53,15 @@ If you are testing locally, point OpenCode at the local package or plugin file u
53
53
 
54
54
  ### Engineer session
55
55
 
56
- - `engineer_send` — send a message to the persistent Claude Code session. Auto-creates on first call, resumes on subsequent calls.
56
+ - `explore` — investigate and analyze code without making edits. Read-only exploration of the codebase. Preferred first step before implementation.
57
57
  - `message` (required) — the instruction to send.
58
- - `mode` — `"plan"` (read-only investigation) or `"free"` (default, normal execution with edits).
59
58
  - `freshSession` — set to `true` to clear the active session before sending. Use when switching to an unrelated task or when context is contaminated.
60
59
  - `model` — `"claude-opus-4-6"` (default, recommended for most coding work), `"claude-sonnet-4-6"`, or `"claude-sonnet-4-5"` (faster/lighter tasks).
61
60
  - `effort` — `"high"` (default), `"medium"` (lighter tasks), `"low"`, or `"max"` (especially hard problems).
62
- - `engineer_compact` — compress the active session context while preserving session state. Use before clearing when context is high but salvageable.
63
- - `engineer_clear` — drop the active session entirely; next send starts fresh.
64
- - `engineer_status` — get current session health: context %, turns, cost, session ID.
61
+ - `implement` — implement code changes; can read, edit, and create files. Use after exploration to make changes. Same args as `explore`.
62
+ - `compact_context` — compress session history to reclaim context window space. Preserves state while reducing token usage.
63
+ - `clear_session` — clear the active session to start fresh. Use when context is full or starting a new task.
64
+ - `session_health` — check session health metrics: context usage %, turn count, cost, and session ID.
65
65
 
66
66
  ### Git operations
67
67
 
@@ -71,9 +71,8 @@ If you are testing locally, point OpenCode at the local package or plugin file u
71
71
 
72
72
  ### Inspection
73
73
 
74
- - `engineer_metadata` — inspect available Claude commands, skills, hooks, and settings.
75
- - `engineer_sessions` — list Claude sessions or inspect a saved transcript.
76
- - `engineer_runs` — list or inspect persisted manager run records (may be empty if tasks were sent directly via `engineer_send` rather than the run-tracking path).
74
+ - `list_transcripts` — list available session transcripts or inspect a specific transcript by ID.
75
+ - `list_history` — list persistent run records from the manager or inspect a specific run.
77
76
 
78
77
  ### Tool approval
79
78
 
@@ -86,7 +85,7 @@ If you are testing locally, point OpenCode at the local package or plugin file u
86
85
  The plugin registers a CTO → Manager → Engineer hierarchy through the OpenCode plugin `config` hook:
87
86
 
88
87
  - **`cto`** (primary agent) — sets direction and orchestrates work by spawning `manager` subagents. Has read/search/web tools but does NOT operate Claude Code directly.
89
- - **`manager`** (subagent) — operates a Claude Code engineer through a persistent session. Has the full tool surface (`engineer_*`, `git_*`, `approval_*`) plus read/search/web tools for investigation.
88
+ - **`manager`** (subagent) — operates a Claude Code engineer through a persistent session. Has the full tool surface (`explore`, `implement`, `compact_context`, `clear_session`, `session_health`, `list_transcripts`, `list_history`, `git_*`, `approval_*`) plus read/search/web tools for investigation.
90
89
  - **Engineer** — the Claude Code persistent session itself (not an OpenCode agent). Receives instructions from the manager, executes code changes, and reports results.
91
90
 
92
91
  These are added to OpenCode config at runtime by the plugin, so they do not require separate manual `opencode.json` entries.
@@ -95,27 +94,27 @@ These are added to OpenCode config at runtime by the plugin, so they do not requ
95
94
 
96
95
  Typical flow inside OpenCode:
97
96
 
98
- 1. Inspect Claude capabilities with `engineer_metadata`.
99
- 2. Delegate work with `engineer_send`.
97
+ 1. Explore the codebase with `explore`.
98
+ 2. Implement changes with `implement`.
100
99
  3. Review changes with `git_diff`, then commit or reset.
101
- 4. Inspect saved Claude history with `engineer_sessions` or prior orchestration records with `engineer_runs`.
100
+ 4. Inspect saved Claude history with `list_transcripts` or prior orchestration records with `list_history`.
102
101
 
103
102
  Example tasks:
104
103
 
105
104
  ```text
106
- Use engineer_send to implement the new validation logic in src/auth.ts, then review with git_diff.
105
+ Use implement to add the new validation logic in src/auth.ts, then review with git_diff.
107
106
  ```
108
107
 
109
108
  Start a fresh session for an unrelated task:
110
109
 
111
110
  ```text
112
- Use engineer_send with freshSession:true to investigate the failing CI test in test/api.test.ts using mode:"plan".
111
+ Use explore with freshSession:true to investigate the failing CI test in test/api.test.ts.
113
112
  ```
114
113
 
115
114
  Reclaim context mid-session:
116
115
 
117
116
  ```text
118
- Use engineer_compact to free up context, then continue with the next implementation step.
117
+ Use compact_context to free up context, then continue with the next implementation step.
119
118
  ```
120
119
 
121
120
  ## Local Development
@@ -409,7 +409,7 @@ function truncateJsonish(value, max) {
409
409
  return truncateString(JSON.stringify(value), max);
410
410
  }
411
411
  catch {
412
- return truncateString(String(value), max);
412
+ return truncateString('[non-serializable]', max);
413
413
  }
414
414
  }
415
415
  function truncateString(s, max) {
@@ -99,7 +99,7 @@ export class SessionLiveTailer {
99
99
  });
100
100
  let chunk = '';
101
101
  stream.on('data', (data) => {
102
- chunk += data;
102
+ chunk += typeof data === 'string' ? data : data.toString('utf8');
103
103
  });
104
104
  stream.on('end', () => {
105
105
  reading = false;
@@ -264,6 +264,6 @@ function stringifyContent(value) {
264
264
  return JSON.stringify(value);
265
265
  }
266
266
  catch {
267
- return String(value);
267
+ return '[non-serializable]';
268
268
  }
269
269
  }
@@ -11,7 +11,7 @@ export declare const AGENT_CTO = "cto";
11
11
  export declare const AGENT_ENGINEER_PLAN = "engineer_plan";
12
12
  export declare const AGENT_ENGINEER_BUILD = "engineer_build";
13
13
  /** All restricted tool IDs (union of all domain groups) */
14
- export declare const ALL_RESTRICTED_TOOL_IDS: readonly ["engineer_send", "engineer_send_plan", "engineer_send_build", "engineer_compact", "engineer_clear", "engineer_status", "engineer_sessions", "engineer_runs", "git_diff", "git_commit", "git_reset", "approval_policy", "approval_decisions", "approval_update"];
14
+ export declare const ALL_RESTRICTED_TOOL_IDS: readonly ["explore", "implement", "compact_context", "clear_session", "session_health", "list_transcripts", "list_history", "git_diff", "git_commit", "git_reset", "approval_policy", "approval_decisions", "approval_update"];
15
15
  type ToolPermission = 'allow' | 'ask' | 'deny';
16
16
  type AgentPermission = {
17
17
  '*'?: ToolPermission;
@@ -17,23 +17,18 @@ export const AGENT_ENGINEER_BUILD = 'engineer_build';
17
17
  // ---------------------------------------------------------------------------
18
18
  /** Shared engineer session tools (compact, clear, status, diagnostics) */
19
19
  const ENGINEER_SHARED_TOOL_IDS = [
20
- 'engineer_compact',
21
- 'engineer_clear',
22
- 'engineer_status',
23
- 'engineer_sessions',
24
- 'engineer_runs',
25
- ];
26
- /** All engineer tools — generic send + mode-locked sends + shared session tools */
27
- const ENGINEER_TOOL_IDS = [
28
- 'engineer_send',
29
- 'engineer_send_plan',
30
- 'engineer_send_build',
31
- ...ENGINEER_SHARED_TOOL_IDS,
20
+ 'compact_context',
21
+ 'clear_session',
22
+ 'session_health',
23
+ 'list_transcripts',
24
+ 'list_history',
32
25
  ];
26
+ /** All engineer tools — mode-locked sends + shared session tools */
27
+ const ENGINEER_TOOL_IDS = ['explore', 'implement', ...ENGINEER_SHARED_TOOL_IDS];
33
28
  /** Tools for the engineer_plan wrapper (plan-mode send + shared) */
34
- const ENGINEER_PLAN_TOOL_IDS = ['engineer_send_plan', ...ENGINEER_SHARED_TOOL_IDS];
29
+ const ENGINEER_PLAN_TOOL_IDS = ['explore', ...ENGINEER_SHARED_TOOL_IDS];
35
30
  /** Tools for the engineer_build wrapper (build-mode send + shared) */
36
- const ENGINEER_BUILD_TOOL_IDS = ['engineer_send_build', ...ENGINEER_SHARED_TOOL_IDS];
31
+ const ENGINEER_BUILD_TOOL_IDS = ['implement', ...ENGINEER_SHARED_TOOL_IDS];
37
32
  /** Git tools — owned by CTO */
38
33
  const GIT_TOOL_IDS = ['git_diff', 'git_commit', 'git_reset'];
39
34
  /** Approval tools — owned by CTO */
@@ -87,7 +82,7 @@ function buildCtoPermissions() {
87
82
  },
88
83
  };
89
84
  }
90
- /** Engineer plan wrapper: read-only investigation + engineer_send_plan + shared session tools. */
85
+ /** Engineer plan wrapper: read-only investigation + explore + shared session tools. */
91
86
  function buildEngineerPlanPermissions() {
92
87
  const denied = {};
93
88
  for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
@@ -104,7 +99,7 @@ function buildEngineerPlanPermissions() {
104
99
  ...allowed,
105
100
  };
106
101
  }
107
- /** Engineer build wrapper: read-only investigation + engineer_send_build + shared session tools. */
102
+ /** Engineer build wrapper: read-only investigation + implement + shared session tools. */
108
103
  function buildEngineerBuildPermissions() {
109
104
  const denied = {};
110
105
  for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
@@ -5,7 +5,7 @@ import { AGENT_CTO, AGENT_ENGINEER_BUILD, AGENT_ENGINEER_PLAN, buildCtoAgentConf
5
5
  import { getOrCreatePluginServices } from './service-factory.js';
6
6
  export const ClaudeManagerPlugin = async ({ worktree }) => {
7
7
  const services = getOrCreatePluginServices(worktree);
8
- async function executeEngineerSend(args, context) {
8
+ async function executeDelegate(args, context) {
9
9
  const cwd = args.cwd ?? context.worktree;
10
10
  if (args.freshSession) {
11
11
  await services.manager.clearSession(cwd);
@@ -13,8 +13,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
13
13
  const hasActiveSession = services.manager.getStatus().sessionId !== null;
14
14
  const promptPreview = args.message.length > 100 ? args.message.slice(0, 100) + '...' : args.message;
15
15
  context.metadata({
16
- title: hasActiveSession ? 'Claude Code: Resuming session...' : 'Claude Code: Initializing...',
16
+ title: hasActiveSession
17
+ ? '⚡ Claude Code: Resuming session...'
18
+ : '⚡ Claude Code: Initializing...',
17
19
  metadata: {
20
+ status: 'running',
18
21
  sessionId: services.manager.getStatus().sessionId,
19
22
  prompt: promptPreview,
20
23
  },
@@ -49,8 +52,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
49
52
  // ignore parse errors
50
53
  }
51
54
  context.metadata({
52
- title: `Claude Code: Running ${toolName}...${usageSuffix}`,
55
+ title: `⚡ Claude Code: Running ${toolName}...${usageSuffix}`,
53
56
  metadata: {
57
+ status: 'running',
54
58
  sessionId: event.sessionId,
55
59
  type: event.type,
56
60
  tool: toolName,
@@ -61,8 +65,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
61
65
  else if (event.type === 'assistant') {
62
66
  const thinkingPreview = event.text.length > 150 ? event.text.slice(0, 150) + '...' : event.text;
63
67
  context.metadata({
64
- title: `Claude Code: Thinking...${usageSuffix}`,
68
+ title: `⚡ Claude Code: Thinking...${usageSuffix}`,
65
69
  metadata: {
70
+ status: 'running',
66
71
  sessionId: event.sessionId,
67
72
  type: event.type,
68
73
  thinking: thinkingPreview,
@@ -71,8 +76,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
71
76
  }
72
77
  else if (event.type === 'init') {
73
78
  context.metadata({
74
- title: `Claude Code: Session started`,
79
+ title: `⚡ Claude Code: Session started`,
75
80
  metadata: {
81
+ status: 'running',
76
82
  sessionId: event.sessionId,
77
83
  prompt: promptPreview,
78
84
  },
@@ -80,9 +86,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
80
86
  }
81
87
  else if (event.type === 'user') {
82
88
  const preview = event.text.length > 200 ? event.text.slice(0, 200) + '...' : event.text;
89
+ const outputPreview = formatToolOutputPreview(event.text);
83
90
  context.metadata({
84
- title: `Claude Code: Tool result${usageSuffix}`,
91
+ title: `⚡ Claude Code: ${outputPreview}${usageSuffix}`,
85
92
  metadata: {
93
+ status: 'running',
86
94
  sessionId: event.sessionId,
87
95
  type: event.type,
88
96
  output: preview,
@@ -92,17 +100,25 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
92
100
  else if (event.type === 'tool_progress') {
93
101
  let toolName = 'tool';
94
102
  let elapsed = 0;
103
+ let progressCurrent;
104
+ let progressTotal;
95
105
  try {
96
106
  const parsed = JSON.parse(event.text);
97
107
  toolName = parsed.name ?? 'tool';
98
108
  elapsed = parsed.elapsed ?? 0;
109
+ progressCurrent = parsed.current;
110
+ progressTotal = parsed.total;
99
111
  }
100
112
  catch {
101
113
  // ignore
102
114
  }
115
+ const progressInfo = progressCurrent !== undefined && progressTotal !== undefined
116
+ ? ` [${progressCurrent}/${progressTotal}]`
117
+ : '';
103
118
  context.metadata({
104
- title: `Claude Code: ${toolName} running ${elapsed > 0 ? `(${elapsed.toFixed(0)}s)` : ''}...${usageSuffix}`,
119
+ title: `⚡ Claude Code: ${toolName} running ${elapsed > 0 ? `(${elapsed.toFixed(0)}s)` : ''}${progressInfo}...${usageSuffix}`,
105
120
  metadata: {
121
+ status: 'running',
106
122
  sessionId: event.sessionId,
107
123
  type: event.type,
108
124
  tool: toolName,
@@ -113,8 +129,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
113
129
  else if (event.type === 'tool_summary') {
114
130
  const summary = event.text.length > 200 ? event.text.slice(0, 200) + '...' : event.text;
115
131
  context.metadata({
116
- title: `Claude Code: Tool done${usageSuffix}`,
132
+ title: `✅ Claude Code: Tool done${usageSuffix}`,
117
133
  metadata: {
134
+ status: 'success',
118
135
  sessionId: event.sessionId,
119
136
  type: event.type,
120
137
  summary,
@@ -124,8 +141,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
124
141
  else if (event.type === 'partial') {
125
142
  const delta = event.text.length > 200 ? event.text.slice(0, 200) + '...' : event.text;
126
143
  context.metadata({
127
- title: `Claude Code: Writing...${usageSuffix}`,
144
+ title: `⚡ Claude Code: Writing...${usageSuffix}`,
128
145
  metadata: {
146
+ status: 'running',
129
147
  sessionId: event.sessionId,
130
148
  type: event.type,
131
149
  delta,
@@ -134,12 +152,14 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
134
152
  }
135
153
  else if (event.type === 'error') {
136
154
  context.metadata({
137
- title: `Claude Code: Error`,
155
+ title: `❌ Claude Code: Error`,
138
156
  metadata: {
157
+ status: 'error',
139
158
  sessionId: event.sessionId,
140
159
  error: event.text.slice(0, 200),
141
160
  },
142
161
  });
162
+ showToastIfAvailable(context, `Claude Code error: ${event.text.slice(0, 100)}`);
143
163
  }
144
164
  });
145
165
  const costLabel = `$${(result.totalCostUsd ?? 0).toFixed(4)}`;
@@ -147,15 +167,17 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
147
167
  const contextWarning = formatContextWarning(result.context);
148
168
  if (contextWarning) {
149
169
  context.metadata({
150
- title: `Claude Code: Context at ${result.context.estimatedContextPercent}% (${turns} turns)`,
151
- metadata: { sessionId: result.sessionId, contextWarning },
170
+ title: `⚠️ Claude Code: Context at ${result.context.estimatedContextPercent}% (${turns} turns)`,
171
+ metadata: { status: 'warning', sessionId: result.sessionId, contextWarning },
152
172
  });
173
+ showToastIfAvailable(context, `⚠️ Context usage at ${result.context.estimatedContextPercent}% — consider compacting`);
153
174
  }
154
175
  else {
155
176
  context.metadata({
156
- title: `Claude Code: Complete (${turns} turns, ${costLabel})`,
157
- metadata: { sessionId: result.sessionId },
177
+ title: `✅ Claude Code: Complete (${turns} turns, ${costLabel})`,
178
+ metadata: { status: 'success', sessionId: result.sessionId },
158
179
  });
180
+ showToastIfAvailable(context, `✅ Session complete (${turns} turns, ${costLabel})`);
159
181
  }
160
182
  let toolOutputs = [];
161
183
  if (result.sessionId) {
@@ -196,29 +218,26 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
196
218
  config.agent[AGENT_ENGINEER_BUILD] ??= buildEngineerBuildAgentConfig(derivedPrompts);
197
219
  },
198
220
  tool: {
199
- engineer_send: tool({
200
- description: 'Send a message to the persistent Claude Code session with explicit mode control. ' +
201
- 'Most agents should use engineer_send_plan or engineer_send_build instead.',
221
+ explore: tool({
222
+ description: 'Investigate and analyze code without making edits. ' +
223
+ 'Read-only exploration of the codebase. ' +
224
+ 'Preferred first step before implementation.',
202
225
  args: {
203
226
  message: tool.schema.string().min(1),
204
227
  model: tool.schema
205
228
  .enum(['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-sonnet-4-5'])
206
229
  .optional(),
207
230
  effort: tool.schema.enum(['low', 'medium', 'high', 'max']).default('high'),
208
- mode: tool.schema.enum(['plan', 'free']).default('free'),
209
231
  freshSession: tool.schema.boolean().default(false),
210
232
  cwd: tool.schema.string().optional(),
211
233
  },
212
234
  async execute(args, context) {
213
- return executeEngineerSend(args, context);
235
+ return executeDelegate({ ...args, mode: 'plan' }, context);
214
236
  },
215
237
  }),
216
- engineer_send_plan: tool({
217
- description: 'Preferred path for repo exploration and investigation. ' +
218
- 'Send a read-only investigation message to the Claude Code session in plan mode. ' +
219
- 'The engineer will analyze code without making edits. ' +
220
- 'Auto-creates a session on first call. Resumes the existing session on subsequent calls. ' +
221
- 'Returns the assistant response and current context health snapshot.',
238
+ implement: tool({
239
+ description: 'Implement code changes - can read, edit, and create files. ' +
240
+ 'Use after exploration to make changes.',
222
241
  args: {
223
242
  message: tool.schema.string().min(1),
224
243
  model: tool.schema
@@ -229,35 +248,12 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
229
248
  cwd: tool.schema.string().optional(),
230
249
  },
231
250
  async execute(args, context) {
232
- return executeEngineerSend({ ...args, mode: 'plan' }, context);
251
+ return executeDelegate({ ...args, mode: 'free' }, context);
233
252
  },
234
253
  }),
235
- engineer_send_build: tool({
236
- description: 'Preferred path for implementation after framing. ' +
237
- 'Send an implementation message to the Claude Code session in free mode. ' +
238
- 'The engineer can read, edit, and create files. ' +
239
- 'Auto-creates a session on first call. Resumes the existing session on subsequent calls. ' +
240
- 'Returns the assistant response and current context health snapshot. ' +
241
- 'Prefer claude-opus-4-6 (default) for most coding work; use a Sonnet model for faster/lighter tasks. ' +
242
- 'Prefer effort "high" (default) for most work; use "medium" for lighter tasks and "max" for especially hard problems.',
243
- args: {
244
- message: tool.schema.string().min(1),
245
- model: tool.schema
246
- .enum(['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-sonnet-4-5'])
247
- .optional(),
248
- effort: tool.schema.enum(['low', 'medium', 'high', 'max']).default('high'),
249
- freshSession: tool.schema.boolean().default(false),
250
- cwd: tool.schema.string().optional(),
251
- },
252
- async execute(args, context) {
253
- return executeEngineerSend({ ...args, mode: 'free' }, context);
254
- },
255
- }),
256
- engineer_compact: tool({
257
- description: 'Compact the active Claude Code session to reclaim context space. ' +
258
- 'Sends /compact to the session, which compresses prior conversation while preserving state. ' +
259
- 'Use before clearing when context is high but the session still has useful state. ' +
260
- 'Fails if there is no active session.',
254
+ compact_context: tool({
255
+ description: 'Compress session history to reclaim context window space. ' +
256
+ 'Preserves state while reducing token usage.',
261
257
  args: {
262
258
  cwd: tool.schema.string().optional(),
263
259
  },
@@ -269,9 +265,12 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
269
265
  const contextWarning = formatContextWarning(snap);
270
266
  context.metadata({
271
267
  title: contextWarning
272
- ? `Claude Code: Compacted — context at ${snap.estimatedContextPercent}%`
273
- : `Claude Code: Compacted (${snap.totalTurns} turns, $${(snap.totalCostUsd ?? 0).toFixed(4)})`,
274
- metadata: { sessionId: result.sessionId },
268
+ ? `⚠️ Claude Code: Compacted — context at ${snap.estimatedContextPercent}%`
269
+ : `✅ Claude Code: Compacted (${snap.totalTurns} turns, $${(snap.totalCostUsd ?? 0).toFixed(4)})`,
270
+ metadata: {
271
+ status: contextWarning ? 'warning' : 'success',
272
+ sessionId: result.sessionId,
273
+ },
275
274
  });
276
275
  return JSON.stringify({
277
276
  sessionId: result.sessionId,
@@ -319,9 +318,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
319
318
  return JSON.stringify(result, null, 2);
320
319
  },
321
320
  }),
322
- engineer_clear: tool({
323
- description: 'Clear the active Claude Code session. The next send will start a fresh session. ' +
324
- 'Use when context is full, the session is confused, or starting a different task.',
321
+ clear_session: tool({
322
+ description: 'Clear the active session to start fresh. ' +
323
+ 'Use when context is full or starting a new task.',
325
324
  args: {
326
325
  cwd: tool.schema.string().optional(),
327
326
  reason: tool.schema.string().optional(),
@@ -334,8 +333,8 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
334
333
  return JSON.stringify({ clearedSessionId: clearedId });
335
334
  },
336
335
  }),
337
- engineer_status: tool({
338
- description: 'Get the current persistent session status: context usage %, turns, cost, active session ID.',
336
+ session_health: tool({
337
+ description: 'Check session health metrics: context usage %, turn count, cost, and session ID.',
339
338
  args: {
340
339
  cwd: tool.schema.string().optional(),
341
340
  },
@@ -351,9 +350,8 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
351
350
  }, null, 2);
352
351
  },
353
352
  }),
354
- engineer_sessions: tool({
355
- description: 'List Claude sessions or inspect a saved transcript. ' +
356
- 'When sessionId is provided, returns both SDK transcript and local events.',
353
+ list_transcripts: tool({
354
+ description: 'List available session transcripts or inspect a specific transcript by ID.',
357
355
  args: {
358
356
  cwd: tool.schema.string().optional(),
359
357
  sessionId: tool.schema.string().optional(),
@@ -375,8 +373,8 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
375
373
  return JSON.stringify(sessions, null, 2);
376
374
  },
377
375
  }),
378
- engineer_runs: tool({
379
- description: 'List persistent manager run records.',
376
+ list_history: tool({
377
+ description: 'List persistent run records from the manager or inspect a specific run.',
380
378
  args: {
381
379
  cwd: tool.schema.string().optional(),
382
380
  runId: tool.schema.string().optional(),
@@ -482,8 +480,12 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
482
480
  },
483
481
  };
484
482
  };
485
- function annotateToolRun(context, title, metadata) {
486
- context.metadata({ title, metadata });
483
+ function annotateToolRun(context, title, metadata, status) {
484
+ const emoji = status ? `${formatStatusEmoji(status)} ` : '';
485
+ context.metadata({
486
+ title: `${emoji}${title}`,
487
+ metadata: { ...metadata, ...(status ? { status } : {}) },
488
+ });
487
489
  }
488
490
  function formatLiveUsage(turns, cost) {
489
491
  if (turns === undefined && cost === undefined) {
@@ -491,10 +493,10 @@ function formatLiveUsage(turns, cost) {
491
493
  }
492
494
  const parts = [];
493
495
  if (turns !== undefined) {
494
- parts.push(`${turns} turns`);
496
+ parts.push(`🔄 ${turns} turns`);
495
497
  }
496
498
  if (cost !== undefined) {
497
- parts.push(`$${cost.toFixed(4)}`);
499
+ parts.push(`💰 $${cost.toFixed(4)}`);
498
500
  }
499
501
  return ` (${parts.join(', ')})`;
500
502
  }
@@ -514,3 +516,47 @@ function formatContextWarning(context) {
514
516
  .replace('{turns}', String(totalTurns))
515
517
  .replace('{cost}', totalCostUsd.toFixed(2));
516
518
  }
519
+ function formatStatusEmoji(status) {
520
+ switch (status) {
521
+ case 'running':
522
+ return '⚡';
523
+ case 'success':
524
+ return '✅';
525
+ case 'error':
526
+ return '❌';
527
+ case 'warning':
528
+ return '⚠️';
529
+ }
530
+ }
531
+ function formatToolOutputPreview(text) {
532
+ const lower = text.toLowerCase();
533
+ let prefix;
534
+ if (lower.includes('"tool":"read"') ||
535
+ lower.includes('"name":"read"') ||
536
+ lower.includes('file contents')) {
537
+ prefix = '↳ Read: ';
538
+ }
539
+ else if (lower.includes('"tool":"grep"') ||
540
+ lower.includes('"name":"grep"') ||
541
+ lower.includes('matches found')) {
542
+ prefix = '↳ Found: ';
543
+ }
544
+ else if (lower.includes('"tool":"write"') ||
545
+ lower.includes('"name":"write"') ||
546
+ lower.includes('"tool":"edit"') ||
547
+ lower.includes('"name":"edit"') ||
548
+ lower.includes('file written') ||
549
+ lower.includes('file updated')) {
550
+ prefix = '↳ Wrote: ';
551
+ }
552
+ else {
553
+ prefix = '↳ Result: ';
554
+ }
555
+ const snippet = text.replace(/\s+/g, ' ').trim();
556
+ const truncated = snippet.length > 60 ? snippet.slice(0, 60) + '...' : snippet;
557
+ return `${prefix}${truncated}`;
558
+ }
559
+ function showToastIfAvailable(context, message) {
560
+ const ctx = context;
561
+ ctx.client?.tui?.showToast?.(message);
562
+ }
@@ -148,13 +148,13 @@ export const managerPromptRegistry = {
148
148
  '- Do NOT implement changes yourself — investigation only.',
149
149
  '',
150
150
  '## Behavior',
151
- '- Send the objective to the engineer using engineer_send_plan.',
151
+ '- Send the objective to the engineer using explore.',
152
152
  "- Return the engineer's response verbatim. Do not summarize.",
153
- '- Use freshSession:true on engineer_send_plan when the task is unrelated to prior work.',
153
+ '- Use freshSession:true on explore when the task is unrelated to prior work.',
154
154
  '',
155
155
  '## Context management',
156
- '- Check engineer_status before sending.',
157
- '- Under 50%: proceed. Over 70%: engineer_compact. Over 85%: engineer_clear.',
156
+ '- Check session_health before sending.',
157
+ '- Under 50%: proceed. Over 70%: compact_context. Over 85%: clear_session.',
158
158
  '',
159
159
  '## Model selection',
160
160
  '- claude-opus-4-6 + high: complex analysis (default).',
@@ -191,13 +191,13 @@ export const managerPromptRegistry = {
191
191
  '- Do NOT implement changes yourself — investigation only.',
192
192
  '',
193
193
  '## Behavior',
194
- '- Send the objective to the engineer using engineer_send_build.',
194
+ '- Send the objective to the engineer using implement.',
195
195
  "- Return the engineer's response verbatim. Do not summarize.",
196
- '- Use freshSession:true on engineer_send_build when the task is unrelated to prior work.',
196
+ '- Use freshSession:true on implement when the task is unrelated to prior work.',
197
197
  '',
198
198
  '## Context management',
199
- '- Check engineer_status before sending.',
200
- '- Under 50%: proceed. Over 70%: engineer_compact. Over 85%: engineer_clear.',
199
+ '- Check session_health before sending.',
200
+ '- Under 50%: proceed. Over 70%: compact_context. Over 85%: clear_session.',
201
201
  '',
202
202
  '## Model selection',
203
203
  '- claude-opus-4-6 + high: most coding tasks (default).',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -1,12 +0,0 @@
1
- import type { ClaudeMetadataSnapshot, ClaudeSettingSource } from '../types/contracts.js';
2
- import type { ClaudeAgentSdkAdapter } from '../claude/claude-agent-sdk-adapter.js';
3
- import type { RepoClaudeConfigReader } from './repo-claude-config-reader.js';
4
- export declare class ClaudeMetadataService {
5
- private readonly configReader;
6
- private readonly sdkAdapter;
7
- constructor(configReader: RepoClaudeConfigReader, sdkAdapter: ClaudeAgentSdkAdapter);
8
- collect(cwd: string, options?: {
9
- includeSdkProbe?: boolean;
10
- settingSources?: ClaudeSettingSource[];
11
- }): Promise<ClaudeMetadataSnapshot>;
12
- }
@@ -1,38 +0,0 @@
1
- export class ClaudeMetadataService {
2
- configReader;
3
- sdkAdapter;
4
- constructor(configReader, sdkAdapter) {
5
- this.configReader = configReader;
6
- this.sdkAdapter = sdkAdapter;
7
- }
8
- async collect(cwd, options = {}) {
9
- const baseSnapshot = await this.configReader.read(cwd);
10
- if (!options.includeSdkProbe) {
11
- return dedupeSnapshot(baseSnapshot);
12
- }
13
- const capabilities = await this.sdkAdapter.probeCapabilities(cwd, options.settingSources);
14
- return dedupeSnapshot({
15
- ...baseSnapshot,
16
- commands: [...baseSnapshot.commands, ...capabilities.commands],
17
- agents: capabilities.agents,
18
- });
19
- }
20
- }
21
- function dedupeSnapshot(snapshot) {
22
- return {
23
- ...snapshot,
24
- commands: dedupeByName(snapshot.commands),
25
- skills: dedupeByName(snapshot.skills),
26
- hooks: dedupeByName(snapshot.hooks),
27
- agents: dedupeByName(snapshot.agents),
28
- };
29
- }
30
- function dedupeByName(items) {
31
- const seen = new Map();
32
- for (const item of items) {
33
- if (!seen.has(item.name)) {
34
- seen.set(item.name, item);
35
- }
36
- }
37
- return [...seen.values()].sort((left, right) => left.name.localeCompare(right.name));
38
- }
@@ -1,7 +0,0 @@
1
- import type { ClaudeMetadataSnapshot } from '../types/contracts.js';
2
- export declare class RepoClaudeConfigReader {
3
- read(cwd: string): Promise<ClaudeMetadataSnapshot>;
4
- private readSkills;
5
- private readCommands;
6
- private readSettings;
7
- }
@@ -1,154 +0,0 @@
1
- import { promises as fs } from 'node:fs';
2
- import path from 'node:path';
3
- import JSON5 from 'json5';
4
- export class RepoClaudeConfigReader {
5
- async read(cwd) {
6
- const claudeDirectory = path.join(cwd, '.claude');
7
- const skillsDirectory = path.join(claudeDirectory, 'skills');
8
- const commandsDirectory = path.join(claudeDirectory, 'commands');
9
- const claudeMdCandidates = [
10
- path.join(cwd, 'CLAUDE.md'),
11
- path.join(claudeDirectory, 'CLAUDE.md'),
12
- ];
13
- const collectedAt = new Date().toISOString();
14
- const [skills, commands, settingsResult, claudeMdPath] = await Promise.all([
15
- this.readSkills(skillsDirectory),
16
- this.readCommands(commandsDirectory),
17
- this.readSettings(claudeDirectory),
18
- findFirstExistingPath(claudeMdCandidates),
19
- ]);
20
- return {
21
- collectedAt,
22
- cwd,
23
- commands: [...skillsToCommands(skills), ...commands],
24
- skills,
25
- hooks: settingsResult.hooks,
26
- agents: [],
27
- claudeMdPath: claudeMdPath ?? undefined,
28
- settingsPaths: settingsResult.settingsPaths,
29
- };
30
- }
31
- async readSkills(directory) {
32
- if (!(await pathExists(directory))) {
33
- return [];
34
- }
35
- const entries = await fs.readdir(directory, { withFileTypes: true });
36
- const skills = await Promise.all(entries
37
- .filter((entry) => entry.isDirectory())
38
- .map(async (entry) => {
39
- const skillPath = path.join(directory, entry.name, 'SKILL.md');
40
- if (!(await pathExists(skillPath))) {
41
- return null;
42
- }
43
- const content = await fs.readFile(skillPath, 'utf8');
44
- return {
45
- name: entry.name,
46
- description: extractMarkdownDescription(content),
47
- path: skillPath,
48
- source: 'skill',
49
- };
50
- }));
51
- return skills.filter((skill) => skill !== null);
52
- }
53
- async readCommands(directory) {
54
- if (!(await pathExists(directory))) {
55
- return [];
56
- }
57
- const commandFiles = await collectMarkdownFiles(directory);
58
- const commands = await Promise.all(commandFiles.map(async (commandPath) => {
59
- const content = await fs.readFile(commandPath, 'utf8');
60
- return {
61
- name: path.basename(commandPath, path.extname(commandPath)),
62
- description: extractMarkdownDescription(content),
63
- source: 'command',
64
- path: commandPath,
65
- };
66
- }));
67
- return commands.sort((left, right) => left.name.localeCompare(right.name));
68
- }
69
- async readSettings(claudeDirectory) {
70
- const candidatePaths = [
71
- path.join(claudeDirectory, 'settings.json'),
72
- path.join(claudeDirectory, 'settings.local.json'),
73
- ];
74
- const settingsPaths = [];
75
- const hooks = [];
76
- for (const candidatePath of candidatePaths) {
77
- if (!(await pathExists(candidatePath))) {
78
- continue;
79
- }
80
- settingsPaths.push(candidatePath);
81
- const content = await fs.readFile(candidatePath, 'utf8');
82
- const parsed = JSON5.parse(content);
83
- const hookEntries = Object.entries(parsed.hooks ?? {});
84
- for (const [hookName, hookValue] of hookEntries) {
85
- const hookMatchers = Array.isArray(hookValue) ? hookValue : [hookValue];
86
- for (const hookMatcher of hookMatchers) {
87
- if (!hookMatcher || typeof hookMatcher !== 'object') {
88
- continue;
89
- }
90
- const matcher = typeof hookMatcher.matcher === 'string'
91
- ? hookMatcher.matcher
92
- : undefined;
93
- const commandCount = Array.isArray(hookMatcher.hooks)
94
- ? (hookMatcher.hooks?.length ?? 0)
95
- : 0;
96
- hooks.push({
97
- name: hookName,
98
- matcher,
99
- sourcePath: candidatePath,
100
- commandCount,
101
- });
102
- }
103
- }
104
- }
105
- return {
106
- settingsPaths,
107
- hooks,
108
- };
109
- }
110
- }
111
- function extractMarkdownDescription(markdown) {
112
- const lines = markdown
113
- .split(/\r?\n/)
114
- .map((line) => line.trim())
115
- .filter(Boolean);
116
- const descriptionLine = lines.find((line) => !line.startsWith('#') && !line.startsWith('---'));
117
- return descriptionLine ?? 'No description provided.';
118
- }
119
- async function collectMarkdownFiles(directory) {
120
- const entries = await fs.readdir(directory, { withFileTypes: true });
121
- const files = await Promise.all(entries.map(async (entry) => {
122
- const resolvedPath = path.join(directory, entry.name);
123
- if (entry.isDirectory()) {
124
- return collectMarkdownFiles(resolvedPath);
125
- }
126
- return entry.name.endsWith('.md') ? [resolvedPath] : [];
127
- }));
128
- return files.flat();
129
- }
130
- async function pathExists(candidatePath) {
131
- try {
132
- await fs.access(candidatePath);
133
- return true;
134
- }
135
- catch {
136
- return false;
137
- }
138
- }
139
- async function findFirstExistingPath(candidatePaths) {
140
- for (const candidatePath of candidatePaths) {
141
- if (await pathExists(candidatePath)) {
142
- return candidatePath;
143
- }
144
- }
145
- return null;
146
- }
147
- function skillsToCommands(skills) {
148
- return skills.map((skill) => ({
149
- name: skill.name,
150
- description: skill.description,
151
- source: 'skill',
152
- path: skill.path,
153
- }));
154
- }
@@ -1,2 +0,0 @@
1
- import type { Plugin } from '@opencode-ai/plugin';
2
- export declare const OrchestratorPlugin: Plugin;
@@ -1,116 +0,0 @@
1
- import { prompts } from '../prompts/registry.js';
2
- import { evaluateBashCommand, extractBashCommand, } from '../safety/bash-safety.js';
3
- /**
4
- * Thin OpenCode orchestrator plugin with Claude Code specialist subagents.
5
- *
6
- * - Registers `claude-code` provider via a local shim over ai-sdk-provider-claude-code.
7
- * - Creates one orchestrator agent (uses the user's default OpenCode model).
8
- * - Creates 4 Claude Code subagents: planning + build × opus + sonnet.
9
- * - Enforces bash safety via the permission.ask hook.
10
- *
11
- * NOTE: Claude Code `effort` is not configurable through OpenCode provider/model
12
- * options at this time. The subagent prompts compensate by setting high-quality
13
- * expectations directly.
14
- */
15
- // Resolve the shim path at module load time so it is stable for the lifetime
16
- // of the process. The compiled output for this file sits at dist/plugin/ and
17
- // the shim at dist/providers/, so we walk up one level.
18
- const claudeCodeShimUrl = new URL('../providers/claude-code-wrapper.js', import.meta.url).href;
19
- export const OrchestratorPlugin = async () => {
20
- return {
21
- config: async (config) => {
22
- config.provider ??= {};
23
- config.agent ??= {};
24
- // ── Provider ──────────────────────────────────────────────────────
25
- // Uses a file:// shim so OpenCode's factory-finder heuristic sees only
26
- // createClaudeCode and not createAPICallError from the upstream package.
27
- config.provider['claude-code'] ??= {
28
- npm: claudeCodeShimUrl,
29
- models: {
30
- opus: {
31
- id: 'opus',
32
- name: 'Claude Code Opus 4.6',
33
- },
34
- sonnet: {
35
- id: 'sonnet',
36
- name: 'Claude Code Sonnet 4.6',
37
- },
38
- },
39
- };
40
- // ── Orchestrator (uses user's default model — no model set) ───────
41
- config.agent['opencode-orchestrator'] ??= {
42
- description: 'CTO-level orchestrator that gathers context and delegates coding to Claude Code specialists.',
43
- mode: 'primary',
44
- color: '#D97757',
45
- prompt: prompts.orchestrator,
46
- permission: {
47
- '*': 'deny',
48
- read: 'allow',
49
- grep: 'allow',
50
- glob: 'allow',
51
- list: 'allow',
52
- webfetch: 'allow',
53
- question: 'allow',
54
- todowrite: 'allow',
55
- todoread: 'allow',
56
- task: 'allow',
57
- bash: 'deny',
58
- edit: 'deny',
59
- skill: 'deny',
60
- },
61
- };
62
- // ── Planning subagents ────────────────────────────────────────────
63
- // Claude Code tools (Bash, Read, Write, Edit, …) are executed internally
64
- // by the claude CLI subprocess and streamed back with providerExecuted:true.
65
- // OpenCode's own tools must not be advertised to these agents.
66
- const claudeCodePermissions = {
67
- '*': 'deny',
68
- };
69
- config.agent['claude-code-planning-opus'] ??= {
70
- description: 'Claude Code Opus specialist for investigation, architecture, and planning.',
71
- model: 'claude-code/opus',
72
- mode: 'subagent',
73
- color: 'info',
74
- prompt: prompts.planningAgent,
75
- permission: { ...claudeCodePermissions },
76
- };
77
- config.agent['claude-code-planning-sonnet'] ??= {
78
- description: 'Claude Code Sonnet specialist for lighter investigation and planning.',
79
- model: 'claude-code/sonnet',
80
- mode: 'subagent',
81
- color: 'info',
82
- prompt: prompts.planningAgent,
83
- permission: { ...claudeCodePermissions },
84
- };
85
- // ── Build subagents ───────────────────────────────────────────────
86
- config.agent['claude-code-build-opus'] ??= {
87
- description: 'Claude Code Opus specialist for implementation and validation.',
88
- model: 'claude-code/opus',
89
- mode: 'subagent',
90
- color: 'success',
91
- prompt: prompts.buildAgent,
92
- permission: { ...claudeCodePermissions },
93
- };
94
- config.agent['claude-code-build-sonnet'] ??= {
95
- description: 'Claude Code Sonnet specialist for lighter implementation tasks.',
96
- model: 'claude-code/sonnet',
97
- mode: 'subagent',
98
- color: 'success',
99
- prompt: prompts.buildAgent,
100
- permission: { ...claudeCodePermissions },
101
- };
102
- },
103
- // ── Bash safety via permission.ask hook ────────────────────────────
104
- // Handles both v1 Permission ({ type, pattern }) and v2 PermissionRequest
105
- // ({ permission, patterns }) via runtime narrowing in extractBashCommand.
106
- 'permission.ask': async (input, output) => {
107
- const command = extractBashCommand(input);
108
- if (command === null)
109
- return;
110
- const result = evaluateBashCommand(command);
111
- if (!result.allowed) {
112
- output.status = 'deny';
113
- }
114
- },
115
- };
116
- };
@@ -1,13 +0,0 @@
1
- /**
2
- * Thin re-export shim for ai-sdk-provider-claude-code.
3
- *
4
- * OpenCode's provider loader finds the provider factory by scanning
5
- * `Object.keys(module).find(key => key.startsWith("create"))`. The upstream
6
- * package exports `createAPICallError` before `createClaudeCode`, so OpenCode
7
- * picks the wrong function and `.languageModel` ends up undefined.
8
- *
9
- * This shim re-exports only `createClaudeCode`, making it the sole "create*"
10
- * export. The plugin references this file via a `file://` URL so the upstream
11
- * package is still the actual implementation.
12
- */
13
- export { createClaudeCode } from 'ai-sdk-provider-claude-code';
@@ -1,13 +0,0 @@
1
- /**
2
- * Thin re-export shim for ai-sdk-provider-claude-code.
3
- *
4
- * OpenCode's provider loader finds the provider factory by scanning
5
- * `Object.keys(module).find(key => key.startsWith("create"))`. The upstream
6
- * package exports `createAPICallError` before `createClaudeCode`, so OpenCode
7
- * picks the wrong function and `.languageModel` ends up undefined.
8
- *
9
- * This shim re-exports only `createClaudeCode`, making it the sole "create*"
10
- * export. The plugin references this file via a `file://` URL so the upstream
11
- * package is still the actual implementation.
12
- */
13
- export { createClaudeCode } from 'ai-sdk-provider-claude-code';
@@ -1,21 +0,0 @@
1
- /**
2
- * Minimal bash command safety layer.
3
- * Denies known-dangerous patterns; allows everything else.
4
- */
5
- export type BashSafetyResult = {
6
- allowed: true;
7
- } | {
8
- allowed: false;
9
- reason: string;
10
- };
11
- export declare function evaluateBashCommand(command: string): BashSafetyResult;
12
- /**
13
- * Extract the bash command string from a permission hook input,
14
- * handling both SDK payload shapes:
15
- *
16
- * v1 Permission: { type: string, pattern?: string | string[], metadata }
17
- * v2 PermissionRequest: { permission: string, patterns: string[], metadata }
18
- *
19
- * Returns `null` when the input is not a bash permission request.
20
- */
21
- export declare function extractBashCommand(input: Record<string, unknown>): string | null;
@@ -1,62 +0,0 @@
1
- /**
2
- * Minimal bash command safety layer.
3
- * Denies known-dangerous patterns; allows everything else.
4
- */
5
- const DENY_PATTERNS = [
6
- { pattern: 'rm -rf /', reason: 'Destructive: rm -rf / is not allowed.' },
7
- {
8
- pattern: 'git push --force',
9
- reason: 'Force push is not allowed.',
10
- },
11
- {
12
- pattern: 'git reset --hard',
13
- reason: 'git reset --hard is not allowed. Use a safer alternative.',
14
- },
15
- ];
16
- export function evaluateBashCommand(command) {
17
- for (const { pattern, reason } of DENY_PATTERNS) {
18
- if (command.includes(pattern)) {
19
- return { allowed: false, reason };
20
- }
21
- }
22
- return { allowed: true };
23
- }
24
- /**
25
- * Extract the bash command string from a permission hook input,
26
- * handling both SDK payload shapes:
27
- *
28
- * v1 Permission: { type: string, pattern?: string | string[], metadata }
29
- * v2 PermissionRequest: { permission: string, patterns: string[], metadata }
30
- *
31
- * Returns `null` when the input is not a bash permission request.
32
- */
33
- export function extractBashCommand(input) {
34
- // Determine the permission kind from whichever field is present.
35
- const kind = typeof input['permission'] === 'string'
36
- ? input['permission']
37
- : typeof input['type'] === 'string'
38
- ? input['type']
39
- : null;
40
- if (kind !== 'bash')
41
- return null;
42
- // Prefer an explicit command in metadata regardless of shape.
43
- const meta = input['metadata'];
44
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
45
- const cmd = meta['command'];
46
- if (typeof cmd === 'string' && cmd.length > 0)
47
- return cmd;
48
- }
49
- // v2: patterns is always string[]
50
- const patterns = input['patterns'];
51
- if (Array.isArray(patterns) && patterns.length > 0) {
52
- return patterns.join(' ');
53
- }
54
- // v1: pattern may be string or string[]
55
- const pattern = input['pattern'];
56
- if (typeof pattern === 'string' && pattern.length > 0)
57
- return pattern;
58
- if (Array.isArray(pattern) && pattern.length > 0) {
59
- return pattern.join(' ');
60
- }
61
- return null;
62
- }