@doingdev/opencode-claude-manager-plugin 0.1.9 → 0.1.11

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.
@@ -0,0 +1,38 @@
1
+ import type { ClaudeSessionRunResult, SessionContextSnapshot } from '../types/contracts.js';
2
+ import type { ClaudeAgentSdkAdapter, ClaudeSessionEventHandler } from '../claude/claude-agent-sdk-adapter.js';
3
+ import type { ContextTracker } from './context-tracker.js';
4
+ export declare class SessionController {
5
+ private readonly sdkAdapter;
6
+ private readonly contextTracker;
7
+ private readonly sessionPrompt;
8
+ private activeSessionId;
9
+ constructor(sdkAdapter: ClaudeAgentSdkAdapter, contextTracker: ContextTracker, sessionPrompt: string);
10
+ get isActive(): boolean;
11
+ get sessionId(): string | null;
12
+ /**
13
+ * Send a message to the persistent session. Creates one if none exists.
14
+ * Returns the session result including usage data.
15
+ */
16
+ sendMessage(cwd: string, message: string, options?: {
17
+ model?: string;
18
+ settingSources?: Array<'user' | 'project' | 'local'>;
19
+ }, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
20
+ /**
21
+ * Send /compact to the current session to compress context.
22
+ */
23
+ compactSession(cwd: string, onEvent?: ClaudeSessionEventHandler): Promise<ClaudeSessionRunResult>;
24
+ /**
25
+ * Clear the current session. The next sendMessage will create a fresh one.
26
+ */
27
+ clearSession(cwd: string): Promise<string | null>;
28
+ /**
29
+ * Get current context tracking snapshot.
30
+ */
31
+ getContextSnapshot(): SessionContextSnapshot;
32
+ /**
33
+ * Try to restore active session from persisted state on startup.
34
+ */
35
+ tryRestore(cwd: string): Promise<boolean>;
36
+ private persistActiveSession;
37
+ private removeActiveSession;
38
+ }
@@ -0,0 +1,135 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ const ACTIVE_SESSION_FILE = '.claude-manager/active-session.json';
4
+ export class SessionController {
5
+ sdkAdapter;
6
+ contextTracker;
7
+ sessionPrompt;
8
+ activeSessionId = null;
9
+ constructor(sdkAdapter, contextTracker, sessionPrompt) {
10
+ this.sdkAdapter = sdkAdapter;
11
+ this.contextTracker = contextTracker;
12
+ this.sessionPrompt = sessionPrompt;
13
+ }
14
+ get isActive() {
15
+ return this.activeSessionId !== null;
16
+ }
17
+ get sessionId() {
18
+ return this.activeSessionId;
19
+ }
20
+ /**
21
+ * Send a message to the persistent session. Creates one if none exists.
22
+ * Returns the session result including usage data.
23
+ */
24
+ async sendMessage(cwd, message, options, onEvent) {
25
+ const input = {
26
+ cwd,
27
+ prompt: message,
28
+ persistSession: true,
29
+ permissionMode: 'acceptEdits',
30
+ includePartialMessages: true,
31
+ model: options?.model,
32
+ settingSources: options?.settingSources ?? ['project', 'local'],
33
+ };
34
+ if (this.activeSessionId) {
35
+ // Resume existing session
36
+ input.resumeSessionId = this.activeSessionId;
37
+ }
38
+ else {
39
+ // New session — apply the expert operator system prompt
40
+ input.systemPrompt = this.sessionPrompt;
41
+ }
42
+ const result = await this.sdkAdapter.runSession(input, onEvent);
43
+ // Track the session ID
44
+ if (result.sessionId) {
45
+ this.activeSessionId = result.sessionId;
46
+ }
47
+ // Update context tracking
48
+ this.contextTracker.recordResult({
49
+ sessionId: result.sessionId,
50
+ turns: result.turns,
51
+ totalCostUsd: result.totalCostUsd,
52
+ inputTokens: result.inputTokens,
53
+ outputTokens: result.outputTokens,
54
+ contextWindowSize: result.contextWindowSize,
55
+ });
56
+ // Persist active session state
57
+ await this.persistActiveSession(cwd);
58
+ return result;
59
+ }
60
+ /**
61
+ * Send /compact to the current session to compress context.
62
+ */
63
+ async compactSession(cwd, onEvent) {
64
+ if (!this.activeSessionId) {
65
+ throw new Error('No active session to compact');
66
+ }
67
+ const result = await this.sendMessage(cwd, '/compact', undefined, onEvent);
68
+ this.contextTracker.recordCompaction();
69
+ return result;
70
+ }
71
+ /**
72
+ * Clear the current session. The next sendMessage will create a fresh one.
73
+ */
74
+ async clearSession(cwd) {
75
+ const clearedId = this.activeSessionId;
76
+ this.activeSessionId = null;
77
+ this.contextTracker.reset();
78
+ await this.removeActiveSession(cwd);
79
+ return clearedId;
80
+ }
81
+ /**
82
+ * Get current context tracking snapshot.
83
+ */
84
+ getContextSnapshot() {
85
+ return this.contextTracker.snapshot();
86
+ }
87
+ /**
88
+ * Try to restore active session from persisted state on startup.
89
+ */
90
+ async tryRestore(cwd) {
91
+ const filePath = join(cwd, ACTIVE_SESSION_FILE);
92
+ try {
93
+ const raw = await readFile(filePath, 'utf-8');
94
+ const state = JSON.parse(raw);
95
+ if (state.sessionId && state.cwd === cwd) {
96
+ this.activeSessionId = state.sessionId;
97
+ this.contextTracker.restore(state);
98
+ return true;
99
+ }
100
+ }
101
+ catch {
102
+ // File doesn't exist or is corrupt — start fresh
103
+ }
104
+ return false;
105
+ }
106
+ async persistActiveSession(cwd) {
107
+ if (!this.activeSessionId) {
108
+ return;
109
+ }
110
+ const snap = this.contextTracker.snapshot();
111
+ const state = {
112
+ sessionId: this.activeSessionId,
113
+ cwd,
114
+ startedAt: new Date().toISOString(),
115
+ totalTurns: snap.totalTurns,
116
+ totalCostUsd: snap.totalCostUsd,
117
+ estimatedContextPercent: snap.estimatedContextPercent,
118
+ contextWindowSize: snap.contextWindowSize,
119
+ latestInputTokens: snap.latestInputTokens,
120
+ };
121
+ const filePath = join(cwd, ACTIVE_SESSION_FILE);
122
+ await mkdir(dirname(filePath), { recursive: true });
123
+ await writeFile(filePath, JSON.stringify(state, null, 2));
124
+ }
125
+ async removeActiveSession(cwd) {
126
+ const filePath = join(cwd, ACTIVE_SESSION_FILE);
127
+ try {
128
+ const { unlink } = await import('node:fs/promises');
129
+ await unlink(filePath);
130
+ }
131
+ catch {
132
+ // File doesn't exist — that's fine
133
+ }
134
+ }
135
+ }
@@ -1,5 +1,5 @@
1
- import type { ManagedSubtaskPlan, ManagerRunMode } from '../types/contracts.js';
1
+ import type { ManagedSubtaskPlan } from '../types/contracts.js';
2
2
  export declare class TaskPlanner {
3
- plan(task: string, mode: ManagerRunMode, maxSubagents: number): ManagedSubtaskPlan[];
3
+ plan(tasks: string[], maxSubagents: number): ManagedSubtaskPlan[];
4
4
  private createPlan;
5
5
  }
@@ -1,20 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  export class TaskPlanner {
3
- plan(task, mode, maxSubagents) {
4
- if (mode === 'single') {
5
- return [this.createPlan('Primary task', task)];
6
- }
7
- const splitCandidates = extractTaskCandidates(task).slice(0, maxSubagents);
8
- if (mode === 'split' && splitCandidates.length === 0) {
9
- return [this.createPlan('Primary task', task)];
10
- }
11
- if (mode === 'auto' && splitCandidates.length < 2) {
12
- return [this.createPlan('Primary task', task)];
13
- }
14
- if (splitCandidates.length === 0) {
15
- return [this.createPlan('Primary task', task)];
16
- }
17
- return splitCandidates.map((candidate, index) => this.createPlan(`Subtask ${index + 1}`, candidate));
3
+ plan(tasks, maxSubagents) {
4
+ return tasks
5
+ .slice(0, maxSubagents)
6
+ .map((task, index) => this.createPlan(tasks.length === 1 ? 'Primary task' : `Subtask ${index + 1}`, task));
18
7
  }
19
8
  createPlan(title, prompt) {
20
9
  return {
@@ -24,19 +13,3 @@ export class TaskPlanner {
24
13
  };
25
14
  }
26
15
  }
27
- function extractTaskCandidates(task) {
28
- const listMatches = task
29
- .split(/\r?\n/)
30
- .map((line) => line.trim())
31
- .filter((line) => /^(-|\*|\d+\.)\s+/.test(line))
32
- .map((line) => line.replace(/^(-|\*|\d+\.)\s+/, '').trim())
33
- .filter(Boolean);
34
- if (listMatches.length > 0) {
35
- return listMatches;
36
- }
37
- const sentenceMatches = task
38
- .split(/;|\n{2,}/)
39
- .map((part) => part.trim())
40
- .filter((part) => part.length > 20);
41
- return sentenceMatches.length > 1 ? sentenceMatches : [];
42
- }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
1
  import { type Plugin } from '@opencode-ai/plugin';
2
- import type { ManagerRunRecord } from '../types/contracts.js';
2
+ import type { PersistentRunRecord } from '../types/contracts.js';
3
3
  export declare const ClaudeManagerPlugin: Plugin;
4
- export declare function formatManagerRunToolResult(run: ManagerRunRecord): string;
4
+ export declare function formatRunToolResult(run: PersistentRunRecord): string;