@khalilgharbaoui/opencode-claude-code-plugin 0.4.23 → 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
@@ -1997,6 +1997,46 @@ var AUTO_CONTINUE_PROMPT = "Continue the task from where you stopped. Do not sum
1997
1997
  function normalizeVisibleText(text) {
1998
1998
  return text.replace(/\s+/g, " ").trim();
1999
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
+ }
2000
2040
  function looksLikeQuestion(text) {
2001
2041
  const normalized = normalizeVisibleText(text).toLowerCase();
2002
2042
  if (!normalized) return false;
@@ -2325,6 +2365,7 @@ var ClaudeCodeLanguageModel = class {
2325
2365
  }
2326
2366
  }
2327
2367
  }
2368
+ if (isAskUserQuestionTool(toolName)) return "deny";
2328
2369
  return this.config.controlRequestBehavior ?? "allow";
2329
2370
  }
2330
2371
  writeControlResponse(proc, requestId, response) {
@@ -2368,9 +2409,10 @@ var ClaudeCodeLanguageModel = class {
2368
2409
  toolName
2369
2410
  });
2370
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}`;
2371
2413
  this.writeControlResponse(proc, requestId, {
2372
2414
  behavior: "deny",
2373
- message: this.config.controlRequestDenyMessage ?? `Denied by opencode-claude-code policy for tool ${toolName}`,
2415
+ message: denyMessage,
2374
2416
  toolUseID: request.tool_use_id
2375
2417
  });
2376
2418
  log.info("control request auto-denied", {
@@ -2688,14 +2730,9 @@ var ClaudeCodeLanguageModel = class {
2688
2730
  thinkingText += block.thinking;
2689
2731
  }
2690
2732
  if (block.type === "tool_use" && block.id && block.name) {
2691
- if (block.name === "AskUserQuestion" || block.name === "ask_user_question") {
2733
+ if (isAskUserQuestionTool(block.name)) {
2692
2734
  const parsedInput = block.input ?? {};
2693
- const question = parsedInput?.question || "Question?";
2694
- responseText += `
2695
-
2696
- _Asking: ${question}_
2697
-
2698
- `;
2735
+ responseText += formatAskUserQuestion(parsedInput);
2699
2736
  continue;
2700
2737
  }
2701
2738
  if (block.name === "ExitPlanMode") {
@@ -3356,22 +3393,12 @@ ${plan}
3356
3393
  parsedInput = JSON.parse(tc.inputJson || "{}");
3357
3394
  } catch {
3358
3395
  }
3359
- if (tc.name === "AskUserQuestion" || tc.name === "ask_user_question") {
3360
- let question = "Question?";
3361
- if (parsedInput?.questions && Array.isArray(parsedInput.questions) && parsedInput.questions.length > 0) {
3362
- question = parsedInput.questions[0].question || parsedInput.questions[0].text || "Question?";
3363
- } else {
3364
- question = parsedInput?.question || parsedInput?.text || "Question?";
3365
- }
3396
+ if (isAskUserQuestionTool(tc.name)) {
3366
3397
  const askId = startTextBlock();
3367
3398
  controller.enqueue({
3368
3399
  type: "text-delta",
3369
3400
  id: askId,
3370
- delta: `
3371
-
3372
- _Asking: ${question}_
3373
-
3374
- `
3401
+ delta: formatAskUserQuestion(parsedInput)
3375
3402
  });
3376
3403
  endTextBlock();
3377
3404
  } else if (tc.name === "ExitPlanMode") {
@@ -3525,23 +3552,12 @@ ${plan}
3525
3552
  name: block.name,
3526
3553
  input: parsedInput
3527
3554
  });
3528
- if (block.name === "AskUserQuestion" || block.name === "ask_user_question") {
3529
- let question = "Question?";
3530
- if (parsedInput?.questions && Array.isArray(parsedInput.questions) && parsedInput.questions.length > 0) {
3531
- const q = parsedInput.questions[0];
3532
- question = q.question || q.text || "Question?";
3533
- } else {
3534
- question = parsedInput?.question || parsedInput?.text || "Question?";
3535
- }
3555
+ if (isAskUserQuestionTool(block.name)) {
3536
3556
  const askId = startTextBlock();
3537
3557
  controller.enqueue({
3538
3558
  type: "text-delta",
3539
3559
  id: askId,
3540
- delta: `
3541
-
3542
- _Asking: ${question}_
3543
-
3544
- `
3560
+ delta: formatAskUserQuestion(parsedInput)
3545
3561
  });
3546
3562
  endTextBlock();
3547
3563
  } else if (block.name === "ExitPlanMode") {