@khalilgharbaoui/opencode-claude-code-plugin 0.4.22 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -313,6 +313,32 @@ Set `permissionMode: "plan"` to forward `--permission-mode plan` to Claude. The
313
313
 
314
314
  ---
315
315
 
316
+ ## AskUserQuestion
317
+
318
+ opencode has no native structured ask-question executor to proxy through (unlike `Bash`/`Task`), so the plugin handles `AskUserQuestion` specially:
319
+
320
+ 1. **It renders the full question.** The tool's payload — every question, header, option label, and option description — is emitted as readable markdown into the assistant stream so the user actually sees the choices (same approach as `ExitPlanMode`).
321
+ 2. **It is never auto-allowed at the CLI gate.** Allowing it would let the headless Claude CLI resolve its own question (no TTY → fabricated/empty answer) and proceed on a guess. `controlRequestBehaviorForTool` hard-denies `AskUserQuestion` and returns a message telling the model to wait for the operator's answer — or, if the run is non-interactive, to proceed with the single most reasonable option and state its assumption rather than stall.
322
+
323
+ This hard-deny sits **below** `controlRequestToolBehaviors` in precedence but **above** the global `controlRequestBehavior`. So:
324
+
325
+ - The global `controlRequestBehavior: "allow"` does **not** override it (interactive setups stay correct by default).
326
+ - An explicit per-tool entry **does**. For a fully unattended/automated deployment that prefers "guess and continue" over "stop and wait", restore the old auto-allow:
327
+
328
+ ```json
329
+ "provider": {
330
+ "claude-code": {
331
+ "options": {
332
+ "controlRequestToolBehaviors": { "AskUserQuestion": "allow" }
333
+ }
334
+ }
335
+ }
336
+ ```
337
+
338
+ With `"allow"`, the Claude CLI answers its own `AskUserQuestion` internally and the run never blocks — appropriate only when no operator is watching and forward progress matters more than a correct decision.
339
+
340
+ ---
341
+
316
342
  ## Compaction
317
343
 
318
344
  When you run `/compact` in opencode, the plugin handles it on a short-lived dedicated Claude CLI spawn instead of routing it through your main conversation process. Three reasons:
@@ -435,6 +461,7 @@ plugin internals.
435
461
  - No streaming of tool inputs as they're being constructed (Anthropic's `input_json_delta`); the plugin emits them once complete.
436
462
  - Raw chain-of-thought is not available. Claude 4 family models ship summarized thinking only. See [Extended thinking](#extended-thinking) for the full picture.
437
463
  - Recommended Claude Code CLI: **2.1.142+**. Older CLIs work for everything else but skip the `--thinking-display` flag, so Claude Opus 4.7 turns may render empty Thinking rows. If something breaks after a Claude Code update, the CLI version is the first thing to check.
464
+ - **Subagent todos require explicit permission.** opencode's task tool gates `todowrite` per subagent: without a `permission: { todowrite: "allow" }` rule on the subagent definition, opencode injects `todowrite: false` into the tools dict and the plugin's synthetic `todowrite` emissions surface as `⚙ invalid todowrite` rows. The built-in `general` subagent denies `todowrite` by default; use a custom subagent for parallel work that needs todo visibility. Subagent todos render inline in the **subagent's** session view (navigate with the TUI's `session.child.next` / `session.parent` commands), not in the parent session's panel.
438
465
 
439
466
  ---
440
467
 
@@ -481,6 +508,16 @@ git push origin master --follow-tags
481
508
 
482
509
  The GitHub Actions workflow at `.github/workflows/publish.yml` runs `npm publish --access public` on tag push (requires `NPM_TOKEN` secret in the repo settings — use a classic automation token so 2FA isn't required at workflow time).
483
510
 
511
+ ## Star History
512
+
513
+ <a href="https://www.star-history.com/?repos=khalilgharbaoui%2Fopencode-claude-code-plugin&type=date&legend=top-left">
514
+ <picture>
515
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=khalilgharbaoui/opencode-claude-code-plugin&type=date&theme=dark&legend=top-left" />
516
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=khalilgharbaoui/opencode-claude-code-plugin&type=date&legend=top-left" />
517
+ <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=khalilgharbaoui/opencode-claude-code-plugin&type=date&legend=top-left" />
518
+ </picture>
519
+ </a>
520
+
484
521
  ## License
485
522
 
486
523
  MIT. See [LICENSE](./LICENSE).
package/dist/index.js CHANGED
@@ -124,6 +124,91 @@ var log = {
124
124
  }
125
125
  };
126
126
 
127
+ // src/todo-ledger.ts
128
+ var ledgers = /* @__PURE__ */ new Map();
129
+ var PENDING_CREATE_TTL_MS = 6e4;
130
+ var TASK_CREATED_PATTERN = /Task\s*#?\s*(\d+)\s+created/i;
131
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed"]);
132
+ function getOrCreate(sessionId) {
133
+ let ledger = ledgers.get(sessionId);
134
+ if (!ledger) {
135
+ ledger = { todos: /* @__PURE__ */ new Map(), pendingCreates: /* @__PURE__ */ new Map() };
136
+ ledgers.set(sessionId, ledger);
137
+ }
138
+ return ledger;
139
+ }
140
+ function prunePending(ledger) {
141
+ const cutoff = Date.now() - PENDING_CREATE_TTL_MS;
142
+ for (const [id, pending] of ledger.pendingCreates) {
143
+ if (pending.createdAt < cutoff) ledger.pendingCreates.delete(id);
144
+ }
145
+ }
146
+ function materialize(ledger) {
147
+ return Array.from(ledger.todos.values());
148
+ }
149
+ function resolveSubject(input) {
150
+ const subject = typeof input?.subject === "string" ? input.subject.trim() : "";
151
+ if (subject) return subject;
152
+ const description = typeof input?.description === "string" ? input.description.trim() : "";
153
+ if (description) return description;
154
+ return "(no subject)";
155
+ }
156
+ function applyTaskCreateToolUse(sessionId, toolUseId, input) {
157
+ if (!sessionId || !toolUseId) return;
158
+ const ledger = getOrCreate(sessionId);
159
+ prunePending(ledger);
160
+ ledger.pendingCreates.set(toolUseId, {
161
+ subject: resolveSubject(input),
162
+ createdAt: Date.now()
163
+ });
164
+ }
165
+ function applyTaskCreateToolResult(sessionId, toolUseId, resultText) {
166
+ if (!sessionId || !toolUseId) return null;
167
+ const ledger = ledgers.get(sessionId);
168
+ if (!ledger) return null;
169
+ const pending = ledger.pendingCreates.get(toolUseId);
170
+ if (!pending) return null;
171
+ ledger.pendingCreates.delete(toolUseId);
172
+ const match = typeof resultText === "string" ? resultText.match(TASK_CREATED_PATTERN) : null;
173
+ if (!match) {
174
+ log.debug("TaskCreate result did not match expected format", { sessionId, toolUseId, resultText });
175
+ return null;
176
+ }
177
+ const claudeId = match[1];
178
+ if (ledger.todos.has(claudeId)) {
179
+ log.debug("TaskCreate result for already-known claude id; overwriting", { sessionId, claudeId });
180
+ }
181
+ ledger.todos.set(claudeId, { id: claudeId, content: pending.subject, status: "pending" });
182
+ return materialize(ledger);
183
+ }
184
+ function applyTaskUpdate(sessionId, input) {
185
+ if (!sessionId) return null;
186
+ const taskId = typeof input?.taskId === "string" ? input.taskId : null;
187
+ if (!taskId) return null;
188
+ const ledger = ledgers.get(sessionId);
189
+ if (!ledger) return null;
190
+ const entry = ledger.todos.get(taskId);
191
+ if (!entry) {
192
+ log.debug("TaskUpdate for unknown task id", { sessionId, taskId });
193
+ return null;
194
+ }
195
+ if (input?.status === "deleted") {
196
+ ledger.todos.delete(taskId);
197
+ return materialize(ledger);
198
+ }
199
+ if (typeof input?.status === "string" && VALID_STATUSES.has(input.status)) {
200
+ entry.status = input.status;
201
+ }
202
+ if (typeof input?.subject === "string" && input.subject.trim().length > 0) {
203
+ entry.content = input.subject.trim();
204
+ }
205
+ return materialize(ledger);
206
+ }
207
+ function clearLedger(sessionId) {
208
+ if (!sessionId) return;
209
+ ledgers.delete(sessionId);
210
+ }
211
+
127
212
  // src/tool-mapping.ts
128
213
  function mapToolInput(name, input) {
129
214
  if (!input) return input;
@@ -199,17 +284,42 @@ var CLAUDE_INTERNAL_TOOLS = /* @__PURE__ */ new Set([
199
284
  "ToolSearch",
200
285
  "Agent",
201
286
  "AskFollowupQuestion",
202
- "TaskCreate",
203
- "TaskUpdate",
204
287
  "TaskList",
205
288
  "TaskGet",
206
289
  "TaskStop"
207
290
  ]);
291
+ function emitTodoWrite(todos) {
292
+ return {
293
+ name: "todowrite",
294
+ input: {
295
+ todos: todos.map((todo) => ({
296
+ id: todo.id,
297
+ content: todo.content,
298
+ status: todo.status,
299
+ priority: "medium"
300
+ }))
301
+ },
302
+ executed: false
303
+ };
304
+ }
208
305
  function mapTool(name, input, opts) {
209
306
  if (CLAUDE_INTERNAL_TOOLS.has(name)) {
210
307
  log.debug("skipping Claude CLI internal tool", { name });
211
308
  return { name, input, executed: true, skip: true };
212
309
  }
310
+ if (name === "TaskCreate") {
311
+ if (opts?.sessionId && opts?.toolUseId) {
312
+ applyTaskCreateToolUse(opts.sessionId, opts.toolUseId, input);
313
+ }
314
+ return { name, input, executed: true, skip: true };
315
+ }
316
+ if (name === "TaskUpdate") {
317
+ if (opts?.sessionId) {
318
+ const list = applyTaskUpdate(opts.sessionId, input);
319
+ if (list !== null) return emitTodoWrite(list);
320
+ }
321
+ return { name, input, executed: true, skip: true };
322
+ }
213
323
  if (name === "EnterPlanMode") return { name: "plan_enter", input: {}, executed: false };
214
324
  if (name === "ExitPlanMode") return { name: "plan_exit", input, executed: false };
215
325
  if (name === "TodoWrite") {
@@ -1181,6 +1291,8 @@ function setClaudeSessionId(key, sessionId) {
1181
1291
  claudeSessions.set(key, sessionId);
1182
1292
  }
1183
1293
  function deleteClaudeSessionId(key) {
1294
+ const claudeSessionId = claudeSessions.get(key);
1295
+ if (claudeSessionId) clearLedger(claudeSessionId);
1184
1296
  claudeSessions.delete(key);
1185
1297
  }
1186
1298
  function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile) {
@@ -1885,6 +1997,46 @@ var AUTO_CONTINUE_PROMPT = "Continue the task from where you stopped. Do not sum
1885
1997
  function normalizeVisibleText(text) {
1886
1998
  return text.replace(/\s+/g, " ").trim();
1887
1999
  }
2000
+ function isAskUserQuestionTool(name) {
2001
+ if (!name) return false;
2002
+ const n = name.toLowerCase();
2003
+ return n === "askuserquestion" || n === "ask_user_question";
2004
+ }
2005
+ function formatAskUserQuestion(input) {
2006
+ const anyInput = input;
2007
+ const questions = Array.isArray(anyInput?.questions) ? anyInput.questions : [];
2008
+ if (questions.length === 0) {
2009
+ const single = anyInput?.question ?? anyInput?.text;
2010
+ const q = typeof single === "string" && single.trim() ? single.trim() : "Question?";
2011
+ return `
2012
+
2013
+ **${q}**
2014
+
2015
+ _Reply with your answer to continue._
2016
+
2017
+ `;
2018
+ }
2019
+ const out = ["\n\n"];
2020
+ const multiQ = questions.length > 1;
2021
+ questions.forEach((q, i) => {
2022
+ const text = typeof q?.question === "string" && q.question.trim() || typeof q?.text === "string" && q.text.trim() || "Question?";
2023
+ const header = typeof q?.header === "string" && q.header.trim() ? q.header.trim() : "";
2024
+ out.push(`**${multiQ ? `${i + 1}. ` : ""}${text}**`);
2025
+ if (header) out.push(` _(${header})_`);
2026
+ out.push("\n\n");
2027
+ const options = Array.isArray(q?.options) ? q.options : [];
2028
+ options.forEach((opt, j) => {
2029
+ const label = typeof opt?.label === "string" && opt.label.trim() || typeof opt === "string" && opt.trim() || `Option ${j + 1}`;
2030
+ const desc = typeof opt?.description === "string" && opt.description.trim() ? ` \u2014 ${opt.description.trim()}` : "";
2031
+ out.push(`${j + 1}. **${label}**${desc}
2032
+ `);
2033
+ });
2034
+ out.push(
2035
+ q?.multiSelect === true ? "\n_Select one or more \u2014 reply with the numbers or labels._\n\n" : "\n_Reply with your choice (the number or label)._\n\n"
2036
+ );
2037
+ });
2038
+ return out.join("");
2039
+ }
1888
2040
  function looksLikeQuestion(text) {
1889
2041
  const normalized = normalizeVisibleText(text).toLowerCase();
1890
2042
  if (!normalized) return false;
@@ -2213,6 +2365,7 @@ var ClaudeCodeLanguageModel = class {
2213
2365
  }
2214
2366
  }
2215
2367
  }
2368
+ if (isAskUserQuestionTool(toolName)) return "deny";
2216
2369
  return this.config.controlRequestBehavior ?? "allow";
2217
2370
  }
2218
2371
  writeControlResponse(proc, requestId, response) {
@@ -2256,9 +2409,10 @@ var ClaudeCodeLanguageModel = class {
2256
2409
  toolName
2257
2410
  });
2258
2411
  } else {
2412
+ const denyMessage = isAskUserQuestionTool(toolName) ? "Your question and its options have already been presented to the operator in full. Prefer to stop here and wait for their answer in the next message \u2014 do not silently guess. But if this is an automated or otherwise non-interactive run where no operator will reply, do not stall: proceed with the single most reasonable option and state, in one line, the assumption you made so it can be corrected later." : this.config.controlRequestDenyMessage ?? `Denied by opencode-claude-code policy for tool ${toolName}`;
2259
2413
  this.writeControlResponse(proc, requestId, {
2260
2414
  behavior: "deny",
2261
- message: this.config.controlRequestDenyMessage ?? `Denied by opencode-claude-code policy for tool ${toolName}`,
2415
+ message: denyMessage,
2262
2416
  toolUseID: request.tool_use_id
2263
2417
  });
2264
2418
  log.info("control request auto-denied", {
@@ -2576,14 +2730,9 @@ var ClaudeCodeLanguageModel = class {
2576
2730
  thinkingText += block.thinking;
2577
2731
  }
2578
2732
  if (block.type === "tool_use" && block.id && block.name) {
2579
- if (block.name === "AskUserQuestion" || block.name === "ask_user_question") {
2733
+ if (isAskUserQuestionTool(block.name)) {
2580
2734
  const parsedInput = block.input ?? {};
2581
- const question = parsedInput?.question || "Question?";
2582
- responseText += `
2583
-
2584
- _Asking: ${question}_
2585
-
2586
- `;
2735
+ responseText += formatAskUserQuestion(parsedInput);
2587
2736
  continue;
2588
2737
  }
2589
2738
  if (block.name === "ExitPlanMode") {
@@ -2703,7 +2852,11 @@ ${plan}
2703
2852
  input: mappedInput,
2704
2853
  executed,
2705
2854
  skip
2706
- } = mapTool(tc.name, tc.args, { webSearch: this.config.webSearch });
2855
+ } = mapTool(tc.name, tc.args, {
2856
+ webSearch: this.config.webSearch,
2857
+ sessionId: getClaudeSessionId(sk),
2858
+ toolUseId: tc.id
2859
+ });
2707
2860
  if (skip) continue;
2708
2861
  content.push({
2709
2862
  type: "tool-call",
@@ -3146,7 +3299,11 @@ ${plan}
3146
3299
  const { name: mappedName, skip, executed } = mapTool(
3147
3300
  block.name,
3148
3301
  void 0,
3149
- { webSearch: self.config.webSearch }
3302
+ {
3303
+ webSearch: self.config.webSearch,
3304
+ sessionId: getClaudeSessionId(sk),
3305
+ toolUseId: block.id
3306
+ }
3150
3307
  );
3151
3308
  if (!skip) {
3152
3309
  controller.enqueue({
@@ -3236,22 +3393,12 @@ ${plan}
3236
3393
  parsedInput = JSON.parse(tc.inputJson || "{}");
3237
3394
  } catch {
3238
3395
  }
3239
- if (tc.name === "AskUserQuestion" || tc.name === "ask_user_question") {
3240
- let question = "Question?";
3241
- if (parsedInput?.questions && Array.isArray(parsedInput.questions) && parsedInput.questions.length > 0) {
3242
- question = parsedInput.questions[0].question || parsedInput.questions[0].text || "Question?";
3243
- } else {
3244
- question = parsedInput?.question || parsedInput?.text || "Question?";
3245
- }
3396
+ if (isAskUserQuestionTool(tc.name)) {
3246
3397
  const askId = startTextBlock();
3247
3398
  controller.enqueue({
3248
3399
  type: "text-delta",
3249
3400
  id: askId,
3250
- delta: `
3251
-
3252
- _Asking: ${question}_
3253
-
3254
- `
3401
+ delta: formatAskUserQuestion(parsedInput)
3255
3402
  });
3256
3403
  endTextBlock();
3257
3404
  } else if (tc.name === "ExitPlanMode") {
@@ -3280,7 +3427,11 @@ ${plan}
3280
3427
  input: mappedInput,
3281
3428
  executed,
3282
3429
  skip
3283
- } = mapTool(tc.name, parsedInput, { webSearch: self.config.webSearch });
3430
+ } = mapTool(tc.name, parsedInput, {
3431
+ webSearch: self.config.webSearch,
3432
+ sessionId: getClaudeSessionId(sk),
3433
+ toolUseId: tc.id
3434
+ });
3284
3435
  if (!skip) {
3285
3436
  toolCallsById.set(tc.id, {
3286
3437
  id: tc.id,
@@ -3401,23 +3552,12 @@ ${plan}
3401
3552
  name: block.name,
3402
3553
  input: parsedInput
3403
3554
  });
3404
- if (block.name === "AskUserQuestion" || block.name === "ask_user_question") {
3405
- let question = "Question?";
3406
- if (parsedInput?.questions && Array.isArray(parsedInput.questions) && parsedInput.questions.length > 0) {
3407
- const q = parsedInput.questions[0];
3408
- question = q.question || q.text || "Question?";
3409
- } else {
3410
- question = parsedInput?.question || parsedInput?.text || "Question?";
3411
- }
3555
+ if (isAskUserQuestionTool(block.name)) {
3412
3556
  const askId = startTextBlock();
3413
3557
  controller.enqueue({
3414
3558
  type: "text-delta",
3415
3559
  id: askId,
3416
- delta: `
3417
-
3418
- _Asking: ${question}_
3419
-
3420
- `
3560
+ delta: formatAskUserQuestion(parsedInput)
3421
3561
  });
3422
3562
  endTextBlock();
3423
3563
  } else if (block.name === "ExitPlanMode") {
@@ -3446,7 +3586,11 @@ ${plan}
3446
3586
  input: mappedInput,
3447
3587
  executed,
3448
3588
  skip
3449
- } = mapTool(block.name, parsedInput, { webSearch: self.config.webSearch });
3589
+ } = mapTool(block.name, parsedInput, {
3590
+ webSearch: self.config.webSearch,
3591
+ sessionId: getClaudeSessionId(sk),
3592
+ toolUseId: block.id
3593
+ });
3450
3594
  if (!skip) {
3451
3595
  if (!executed) skipResultForIds.add(block.id);
3452
3596
  controller.enqueue({
@@ -3487,16 +3631,48 @@ ${plan}
3487
3631
  });
3488
3632
  continue;
3489
3633
  }
3634
+ let resultText = "";
3635
+ if (typeof block.content === "string") {
3636
+ resultText = block.content;
3637
+ } else if (Array.isArray(block.content)) {
3638
+ resultText = block.content.filter(
3639
+ (c) => c.type === "text" && typeof c.text === "string"
3640
+ ).map((c) => c.text).join("\n");
3641
+ }
3642
+ const claudeSessionId = getClaudeSessionId(sk);
3643
+ if (claudeSessionId) {
3644
+ const list = applyTaskCreateToolResult(
3645
+ claudeSessionId,
3646
+ block.tool_use_id,
3647
+ resultText
3648
+ );
3649
+ if (list) {
3650
+ const synthId = `todowrite_${block.tool_use_id}`;
3651
+ controller.enqueue({
3652
+ type: "tool-input-start",
3653
+ id: synthId,
3654
+ toolName: "todowrite",
3655
+ providerExecuted: false
3656
+ });
3657
+ controller.enqueue({
3658
+ type: "tool-call",
3659
+ toolCallId: synthId,
3660
+ toolName: "todowrite",
3661
+ input: JSON.stringify({
3662
+ todos: list.map((t) => ({
3663
+ id: t.id,
3664
+ content: t.content,
3665
+ status: t.status,
3666
+ priority: "medium"
3667
+ }))
3668
+ }),
3669
+ providerExecuted: false
3670
+ });
3671
+ noteToolActivity();
3672
+ }
3673
+ }
3490
3674
  const toolCall = toolCallsById.get(block.tool_use_id);
3491
3675
  if (toolCall) {
3492
- let resultText = "";
3493
- if (typeof block.content === "string") {
3494
- resultText = block.content;
3495
- } else if (Array.isArray(block.content)) {
3496
- resultText = block.content.filter(
3497
- (c) => c.type === "text" && typeof c.text === "string"
3498
- ).map((c) => c.text).join("\n");
3499
- }
3500
3676
  controller.enqueue({
3501
3677
  type: "tool-result",
3502
3678
  toolCallId: block.tool_use_id,