@doingdev/opencode-claude-manager-plugin 0.1.7 → 0.1.10

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.
@@ -110,7 +110,7 @@ export class ClaudeAgentSdkAdapter {
110
110
  settingSources: input.settingSources,
111
111
  maxTurns: input.maxTurns,
112
112
  model: input.model,
113
- permissionMode: input.permissionMode,
113
+ permissionMode: input.permissionMode ?? 'acceptEdits',
114
114
  systemPrompt: input.systemPrompt
115
115
  ? { type: 'preset', preset: 'claude_code', append: input.systemPrompt }
116
116
  : { type: 'preset', preset: 'claude_code' },
@@ -193,20 +193,21 @@ function normalizeSdkMessage(message) {
193
193
  rawType: message.type,
194
194
  };
195
195
  }
196
+ function isRecord(value) {
197
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
198
+ }
196
199
  function extractPartialEventText(event) {
197
- if (!event || typeof event !== 'object') {
200
+ if (!isRecord(event)) {
198
201
  return null;
199
202
  }
200
- const eventRecord = event;
201
- const delta = eventRecord.delta;
202
- if (delta && typeof delta === 'object') {
203
- const deltaRecord = delta;
204
- if (typeof deltaRecord.text === 'string' && deltaRecord.text.length > 0) {
205
- return deltaRecord.text;
203
+ const delta = event.delta;
204
+ if (isRecord(delta)) {
205
+ if (typeof delta.text === 'string' && delta.text.length > 0) {
206
+ return delta.text;
206
207
  }
207
- if (typeof deltaRecord.partial_json === 'string' &&
208
- deltaRecord.partial_json.length > 0) {
209
- return deltaRecord.partial_json;
208
+ if (typeof delta.partial_json === 'string' &&
209
+ delta.partial_json.length > 0) {
210
+ return delta.partial_json;
210
211
  }
211
212
  }
212
213
  return null;
@@ -215,23 +216,21 @@ function extractText(payload) {
215
216
  if (typeof payload === 'string') {
216
217
  return payload;
217
218
  }
218
- if (!payload || typeof payload !== 'object') {
219
+ if (!isRecord(payload)) {
219
220
  return '';
220
221
  }
221
- const payloadRecord = payload;
222
- if (Array.isArray(payloadRecord.content)) {
223
- const parts = payloadRecord.content
222
+ if (Array.isArray(payload.content)) {
223
+ const parts = payload.content
224
224
  .map((contentPart) => {
225
- if (!contentPart || typeof contentPart !== 'object') {
225
+ if (!isRecord(contentPart)) {
226
226
  return '';
227
227
  }
228
- const partRecord = contentPart;
229
- if (typeof partRecord.text === 'string') {
230
- return partRecord.text;
228
+ if (typeof contentPart.text === 'string') {
229
+ return contentPart.text;
231
230
  }
232
- if (partRecord.type === 'tool_use' &&
233
- typeof partRecord.name === 'string') {
234
- return `[tool:${partRecord.name}]`;
231
+ if (contentPart.type === 'tool_use' &&
232
+ typeof contentPart.name === 'string') {
233
+ return `[tool:${contentPart.name}]`;
235
234
  }
236
235
  return '';
237
236
  })
@@ -0,0 +1,7 @@
1
+ import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { DelegatedToolPermissionPolicy } from '../types/contracts.js';
3
+ export type DelegatedCanUseToolOptions = {
4
+ /** Override TTY detection (for tests or custom hosts). */
5
+ isInteractiveTerminal?: () => boolean;
6
+ };
7
+ export declare function createDelegatedCanUseTool(policy: DelegatedToolPermissionPolicy, factoryOptions?: DelegatedCanUseToolOptions): CanUseTool;
@@ -0,0 +1,178 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ function defaultIsInteractiveTerminal() {
3
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
4
+ }
5
+ function isRecord(value) {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ }
8
+ function formatToolRequest(toolName, input, meta) {
9
+ const headline = meta.title?.trim() ||
10
+ [meta.displayName, toolName].filter(Boolean).join(': ') ||
11
+ toolName;
12
+ const lines = [headline];
13
+ if (meta.description?.trim()) {
14
+ lines.push(meta.description.trim());
15
+ }
16
+ if (meta.decisionReason?.trim()) {
17
+ lines.push(`Reason: ${meta.decisionReason.trim()}`);
18
+ }
19
+ if (toolName === 'Bash' && typeof input.command === 'string') {
20
+ lines.push(`Command: ${input.command}`);
21
+ if (typeof input.description === 'string' && input.description) {
22
+ lines.push(`Intent: ${input.description}`);
23
+ }
24
+ }
25
+ else if (toolName !== 'AskUserQuestion') {
26
+ lines.push(`Input: ${JSON.stringify(input, null, 2)}`);
27
+ }
28
+ return lines.join('\n');
29
+ }
30
+ function applyDefaultAskUserAnswers(input) {
31
+ const rawQuestions = input.questions;
32
+ if (!Array.isArray(rawQuestions)) {
33
+ return input;
34
+ }
35
+ const answers = {};
36
+ for (const entry of rawQuestions) {
37
+ if (!isRecord(entry)) {
38
+ continue;
39
+ }
40
+ const questionText = entry.question;
41
+ if (typeof questionText !== 'string') {
42
+ continue;
43
+ }
44
+ const options = entry.options;
45
+ if (!Array.isArray(options) || options.length === 0) {
46
+ continue;
47
+ }
48
+ const first = options[0];
49
+ if (isRecord(first) && typeof first.label === 'string') {
50
+ answers[questionText] = first.label;
51
+ }
52
+ }
53
+ return {
54
+ ...input,
55
+ questions: rawQuestions,
56
+ answers,
57
+ };
58
+ }
59
+ async function promptLine(question) {
60
+ const rl = createInterface({
61
+ input: process.stdin,
62
+ output: process.stdout,
63
+ });
64
+ try {
65
+ return (await rl.question(question)).trim();
66
+ }
67
+ finally {
68
+ rl.close();
69
+ }
70
+ }
71
+ async function handleAskUserQuestionInteractive(input, toolUseID) {
72
+ const rawQuestions = input.questions;
73
+ if (!Array.isArray(rawQuestions)) {
74
+ return {
75
+ behavior: 'deny',
76
+ message: 'AskUserQuestion invoked without a questions array; cannot collect answers interactively.',
77
+ toolUseID,
78
+ };
79
+ }
80
+ const answers = {};
81
+ for (const entry of rawQuestions) {
82
+ if (!isRecord(entry)) {
83
+ continue;
84
+ }
85
+ const questionText = entry.question;
86
+ const header = entry.header;
87
+ if (typeof questionText !== 'string') {
88
+ continue;
89
+ }
90
+ const label = typeof header === 'string' && header.trim()
91
+ ? `${header.trim()}: ${questionText}`
92
+ : questionText;
93
+ const options = entry.options;
94
+ if (!Array.isArray(options) || options.length === 0) {
95
+ return {
96
+ behavior: 'deny',
97
+ message: `Question "${questionText}" has no options to choose from.`,
98
+ toolUseID,
99
+ };
100
+ }
101
+ process.stdout.write(`\n${label}\n`);
102
+ for (const [index, opt] of options.entries()) {
103
+ if (isRecord(opt) && typeof opt.label === 'string') {
104
+ const desc = typeof opt.description === 'string' ? ` — ${opt.description}` : '';
105
+ process.stdout.write(` ${index + 1}. ${opt.label}${desc}\n`);
106
+ }
107
+ }
108
+ const multi = entry.multiSelect === true
109
+ ? ' (numbers separated by commas, or type your own answer)'
110
+ : ' (number, or type your own answer)';
111
+ const response = await promptLine(`Your choice${multi}: `);
112
+ const parsed = parseChoiceResponse(response, options);
113
+ answers[questionText] = parsed;
114
+ }
115
+ return {
116
+ behavior: 'allow',
117
+ updatedInput: {
118
+ ...input,
119
+ questions: rawQuestions,
120
+ answers,
121
+ },
122
+ toolUseID,
123
+ };
124
+ }
125
+ function parseChoiceResponse(response, options) {
126
+ const indices = response
127
+ .split(',')
128
+ .map((part) => Number.parseInt(part.trim(), 10) - 1)
129
+ .filter((index) => Number.isFinite(index));
130
+ const labels = indices
131
+ .filter((index) => index >= 0 && index < options.length)
132
+ .map((index) => {
133
+ const opt = options[index];
134
+ if (isRecord(opt) && typeof opt.label === 'string') {
135
+ return opt.label;
136
+ }
137
+ return '';
138
+ })
139
+ .filter(Boolean);
140
+ return labels.length > 0 ? labels.join(', ') : response;
141
+ }
142
+ export function createDelegatedCanUseTool(policy, factoryOptions) {
143
+ const isInteractiveTerminal = factoryOptions?.isInteractiveTerminal ?? defaultIsInteractiveTerminal;
144
+ return async (toolName, input, options) => {
145
+ const usePrompt = policy === 'prompt_if_tty' && isInteractiveTerminal();
146
+ if (toolName === 'AskUserQuestion') {
147
+ if (usePrompt) {
148
+ return handleAskUserQuestionInteractive(input, options.toolUseID);
149
+ }
150
+ return {
151
+ behavior: 'allow',
152
+ updatedInput: applyDefaultAskUserAnswers(input),
153
+ toolUseID: options.toolUseID,
154
+ };
155
+ }
156
+ if (policy === 'allow_all' || !usePrompt) {
157
+ return {
158
+ behavior: 'allow',
159
+ updatedInput: input,
160
+ toolUseID: options.toolUseID,
161
+ };
162
+ }
163
+ const summary = formatToolRequest(toolName, input, options);
164
+ const answer = await promptLine(`${summary}\nAllow this action? [y/N] `);
165
+ if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
166
+ return {
167
+ behavior: 'allow',
168
+ updatedInput: input,
169
+ toolUseID: options.toolUseID,
170
+ };
171
+ }
172
+ return {
173
+ behavior: 'deny',
174
+ message: 'User denied this tool action in the delegated Claude Code session (TTY prompt).',
175
+ toolUseID: options.toolUseID,
176
+ };
177
+ };
178
+ }
@@ -0,0 +1,15 @@
1
+ import type { ToolContext } from '@opencode-ai/plugin';
2
+ import type { ManagerSessionCanUseToolFactory } from '../types/contracts.js';
3
+ export interface ClaudeCodePermissionBridge {
4
+ createCanUseTool: ManagerSessionCanUseToolFactory;
5
+ }
6
+ /**
7
+ * Bridges Claude Agent SDK tool permission prompts to OpenCode `ToolContext.ask`.
8
+ * Serializes concurrent asks: parallel manager sub-sessions share one OpenCode tool context.
9
+ *
10
+ * AskUserQuestion: OpenCode `ask` does not return selected labels. After approval we return
11
+ * allow with answers set to each question's first option label so the session can proceed;
12
+ * the UI should still show full choices — users needing a different option should answer via
13
+ * the primary agent and re-run.
14
+ */
15
+ export declare function createClaudeCodePermissionBridge(context: ToolContext): ClaudeCodePermissionBridge;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Bridges Claude Agent SDK tool permission prompts to OpenCode `ToolContext.ask`.
3
+ * Serializes concurrent asks: parallel manager sub-sessions share one OpenCode tool context.
4
+ *
5
+ * AskUserQuestion: OpenCode `ask` does not return selected labels. After approval we return
6
+ * allow with answers set to each question's first option label so the session can proceed;
7
+ * the UI should still show full choices — users needing a different option should answer via
8
+ * the primary agent and re-run.
9
+ */
10
+ export function createClaudeCodePermissionBridge(context) {
11
+ const queue = createPermissionAskQueue();
12
+ return {
13
+ createCanUseTool(scope) {
14
+ return (toolName, input, options) => queue.enqueue(() => bridgeCanUseTool(context, scope, toolName, input, options));
15
+ },
16
+ };
17
+ }
18
+ function createPermissionAskQueue() {
19
+ let chain = Promise.resolve();
20
+ return {
21
+ enqueue(operation) {
22
+ const resultPromise = chain
23
+ .catch(() => undefined)
24
+ .then(operation);
25
+ chain = resultPromise.then(() => undefined, () => undefined);
26
+ return resultPromise;
27
+ },
28
+ };
29
+ }
30
+ async function bridgeCanUseTool(context, scope, toolName, input, options) {
31
+ if (options.signal.aborted) {
32
+ return {
33
+ behavior: 'deny',
34
+ message: 'Permission request aborted.',
35
+ toolUseID: options.toolUseID,
36
+ };
37
+ }
38
+ if (toolName === 'AskUserQuestion') {
39
+ return handleAskUserQuestion(context, scope, input, options);
40
+ }
41
+ const permission = mapClaudeToolToOpenCodePermission(toolName);
42
+ const patterns = derivePatterns(toolName, input);
43
+ const metadata = buildMetadata(scope, toolName, input, options);
44
+ try {
45
+ await context.ask({
46
+ permission,
47
+ patterns,
48
+ always: [],
49
+ metadata,
50
+ });
51
+ return {
52
+ behavior: 'allow',
53
+ updatedInput: input,
54
+ toolUseID: options.toolUseID,
55
+ };
56
+ }
57
+ catch (error) {
58
+ return {
59
+ behavior: 'deny',
60
+ message: error instanceof Error
61
+ ? error.message
62
+ : 'OpenCode permission request was rejected or failed.',
63
+ toolUseID: options.toolUseID,
64
+ };
65
+ }
66
+ }
67
+ async function handleAskUserQuestion(context, scope, input, options) {
68
+ const questions = input.questions;
69
+ if (!Array.isArray(questions) || questions.length === 0) {
70
+ return {
71
+ behavior: 'deny',
72
+ message: 'AskUserQuestion invoked without a valid questions array.',
73
+ toolUseID: options.toolUseID,
74
+ };
75
+ }
76
+ const metadata = {
77
+ ...buildMetadata(scope, 'AskUserQuestion', input, options),
78
+ questions,
79
+ note: 'Claude Code AskUserQuestion: approving proceeds with the first listed option per question unless the host supplies structured replies.',
80
+ };
81
+ try {
82
+ await context.ask({
83
+ permission: 'question',
84
+ patterns: [],
85
+ always: [],
86
+ metadata,
87
+ });
88
+ const answers = buildDefaultAskUserQuestionAnswers(questions);
89
+ return {
90
+ behavior: 'allow',
91
+ updatedInput: {
92
+ ...input,
93
+ questions,
94
+ answers,
95
+ },
96
+ toolUseID: options.toolUseID,
97
+ };
98
+ }
99
+ catch (error) {
100
+ return {
101
+ behavior: 'deny',
102
+ message: error instanceof Error
103
+ ? error.message
104
+ : 'OpenCode declined or failed the clarifying-question prompt.',
105
+ toolUseID: options.toolUseID,
106
+ };
107
+ }
108
+ }
109
+ function buildDefaultAskUserQuestionAnswers(questions) {
110
+ const answers = {};
111
+ for (const raw of questions) {
112
+ if (!raw || typeof raw !== 'object') {
113
+ continue;
114
+ }
115
+ const entry = raw;
116
+ const questionText = typeof entry.question === 'string' ? entry.question : '';
117
+ if (!questionText) {
118
+ continue;
119
+ }
120
+ const options = Array.isArray(entry.options) ? entry.options : [];
121
+ const first = options[0];
122
+ const label = first &&
123
+ typeof first === 'object' &&
124
+ first !== null &&
125
+ typeof first.label === 'string'
126
+ ? first.label
127
+ : 'Default';
128
+ answers[questionText] = label;
129
+ }
130
+ return answers;
131
+ }
132
+ function mapClaudeToolToOpenCodePermission(toolName) {
133
+ switch (toolName) {
134
+ case 'Read':
135
+ return 'read';
136
+ case 'Write':
137
+ case 'Edit':
138
+ case 'NotebookEdit':
139
+ return 'edit';
140
+ case 'Bash':
141
+ return 'bash';
142
+ case 'Glob':
143
+ return 'glob';
144
+ case 'Grep':
145
+ return 'grep';
146
+ case 'Task':
147
+ case 'Agent':
148
+ return 'task';
149
+ case 'WebFetch':
150
+ return 'webfetch';
151
+ case 'WebSearch':
152
+ return 'websearch';
153
+ default:
154
+ return 'bash';
155
+ }
156
+ }
157
+ function derivePatterns(toolName, input) {
158
+ const filePath = input.file_path ?? input.path;
159
+ if (typeof filePath === 'string' && filePath.length > 0) {
160
+ return [filePath];
161
+ }
162
+ if (toolName === 'Bash' && typeof input.command === 'string') {
163
+ return [input.command];
164
+ }
165
+ return [];
166
+ }
167
+ function buildMetadata(scope, toolName, input, options) {
168
+ return {
169
+ source: 'claude_code',
170
+ managerRunId: scope.runId,
171
+ managerPlanId: scope.planId,
172
+ managerPlanTitle: scope.planTitle,
173
+ claudeTool: toolName,
174
+ toolInput: input,
175
+ toolUseID: options.toolUseID,
176
+ agentID: options.agentID,
177
+ title: options.title,
178
+ displayName: options.displayName,
179
+ description: options.description,
180
+ blockedPath: options.blockedPath,
181
+ decisionReason: options.decisionReason,
182
+ sdkSuggestions: options.suggestions,
183
+ };
184
+ }
@@ -8,7 +8,6 @@ const MANAGER_TOOL_IDS = [
8
8
  'claude_manager_runs',
9
9
  'claude_manager_cleanup_run',
10
10
  ];
11
- const RESTRICTED_AGENT_TOOLS = buildRestrictedToolMap(MANAGER_TOOL_IDS);
12
11
  export const ClaudeManagerPlugin = async ({ worktree }) => {
13
12
  const services = getOrCreatePluginServices(worktree);
14
13
  return {
@@ -38,8 +37,10 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
38
37
  description: 'Primary agent that manages Claude Code sessions through the bundled plugin tools.',
39
38
  mode: 'primary',
40
39
  color: 'accent',
41
- tools: RESTRICTED_AGENT_TOOLS,
42
- permission: managerPermissions,
40
+ permission: {
41
+ '*': 'deny',
42
+ ...managerPermissions,
43
+ },
43
44
  prompt: [
44
45
  managerPromptRegistry.managerSystemPrompt,
45
46
  'When Claude Code delegation is useful, prefer the claude_manager_run tool instead of simulating the work yourself.',
@@ -51,12 +52,10 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
51
52
  description: 'Subagent that inspects Claude metadata, prior sessions, and manager runs without changing repository state.',
52
53
  mode: 'subagent',
53
54
  color: 'info',
54
- tools: {
55
- ...RESTRICTED_AGENT_TOOLS,
56
- claude_manager_run: false,
57
- claude_manager_cleanup_run: false,
55
+ permission: {
56
+ '*': 'deny',
57
+ ...researchPermissions,
58
58
  },
59
- permission: researchPermissions,
60
59
  prompt: [
61
60
  managerPromptRegistry.subagentSystemPrompt,
62
61
  'Focus on inspection and summarization.',
@@ -250,13 +249,6 @@ function rewriteCommandParts(parts, text) {
250
249
  });
251
250
  return rewrittenParts;
252
251
  }
253
- function buildRestrictedToolMap(toolIds) {
254
- const tools = { '*': false };
255
- for (const toolId of toolIds) {
256
- tools[toolId] = true;
257
- }
258
- return tools;
259
- }
260
252
  export function formatManagerRunToolResult(run) {
261
253
  const finalSummary = run.finalSummary ?? summarizeSessionOutputs(run.sessions);
262
254
  const output = run.sessions.length === 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",