@juspay/shooter 1.12.0 → 1.14.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.
Files changed (96) hide show
  1. package/.claude/hooks/notifier.cjs +414 -91
  2. package/build/client/_app/immutable/chunks/{DfsJh23H.js → Bfg0k16X.js} +1 -1
  3. package/build/client/_app/immutable/chunks/Bfg0k16X.js.br +0 -0
  4. package/build/client/_app/immutable/chunks/Bfg0k16X.js.gz +0 -0
  5. package/build/client/_app/immutable/chunks/{DTGtOxE1.js → CTchCNsn.js} +1 -1
  6. package/build/client/_app/immutable/chunks/CTchCNsn.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/CTchCNsn.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/D8sAtVC-.js +3 -0
  9. package/build/client/_app/immutable/chunks/D8sAtVC-.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/D8sAtVC-.js.gz +0 -0
  11. package/build/client/_app/immutable/entry/{app.B-sEFuLK.js → app.rri2K7zq.js} +2 -2
  12. package/build/client/_app/immutable/entry/app.rri2K7zq.js.br +0 -0
  13. package/build/client/_app/immutable/entry/app.rri2K7zq.js.gz +0 -0
  14. package/build/client/_app/immutable/entry/start.BRqv-XKD.js +1 -0
  15. package/build/client/_app/immutable/entry/start.BRqv-XKD.js.br +2 -0
  16. package/build/client/_app/immutable/entry/start.BRqv-XKD.js.gz +0 -0
  17. package/build/client/_app/immutable/nodes/{0.-0SstbRm.js → 0.DoDuvMbN.js} +1 -1
  18. package/build/client/_app/immutable/nodes/0.DoDuvMbN.js.br +0 -0
  19. package/build/client/_app/immutable/nodes/0.DoDuvMbN.js.gz +0 -0
  20. package/build/client/_app/immutable/nodes/{1.BVLzPogE.js → 1.DZ0g9EOo.js} +1 -1
  21. package/build/client/_app/immutable/nodes/1.DZ0g9EOo.js.br +0 -0
  22. package/build/client/_app/immutable/nodes/1.DZ0g9EOo.js.gz +0 -0
  23. package/build/client/_app/immutable/nodes/{2.CiUyTQg5.js → 2.COrzaySY.js} +1 -1
  24. package/build/client/_app/immutable/nodes/2.COrzaySY.js.br +0 -0
  25. package/build/client/_app/immutable/nodes/2.COrzaySY.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{3.C9vlOBU0.js → 3.sGijgjBd.js} +1 -1
  27. package/build/client/_app/immutable/nodes/3.sGijgjBd.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/3.sGijgjBd.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{6.BSsUBbIT.js → 6.DfVtwT6x.js} +1 -1
  30. package/build/client/_app/immutable/nodes/6.DfVtwT6x.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/6.DfVtwT6x.js.gz +0 -0
  32. package/build/client/_app/immutable/nodes/{7.BIQq9Yuz.js → 7.DFkQ9bmS.js} +1 -1
  33. package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js.br +0 -0
  34. package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js.gz +0 -0
  35. package/build/client/_app/immutable/nodes/{8.BU_sJ5_M.js → 8.BkFDeNg9.js} +1 -1
  36. package/build/client/_app/immutable/nodes/8.BkFDeNg9.js.br +0 -0
  37. package/build/client/_app/immutable/nodes/8.BkFDeNg9.js.gz +0 -0
  38. package/build/client/_app/immutable/nodes/{9.C1vJI771.js → 9.g02G0hlL.js} +1 -1
  39. package/build/client/_app/immutable/nodes/9.g02G0hlL.js.br +0 -0
  40. package/build/client/_app/immutable/nodes/9.g02G0hlL.js.gz +0 -0
  41. package/build/client/_app/version.json +1 -1
  42. package/build/client/_app/version.json.br +0 -0
  43. package/build/client/_app/version.json.gz +0 -0
  44. package/build/server/chunks/{0-DgzcVTc0.js → 0-Cs7l_or5.js} +2 -2
  45. package/build/server/chunks/{0-DgzcVTc0.js.map → 0-Cs7l_or5.js.map} +1 -1
  46. package/build/server/chunks/{1-iMvE8O_M.js → 1-DrEnteaQ.js} +2 -2
  47. package/build/server/chunks/{1-iMvE8O_M.js.map → 1-DrEnteaQ.js.map} +1 -1
  48. package/build/server/chunks/{2-BJrmwHii.js → 2-BxYq6tAd.js} +2 -2
  49. package/build/server/chunks/{2-BJrmwHii.js.map → 2-BxYq6tAd.js.map} +1 -1
  50. package/build/server/chunks/{3-Ds3b4DfT.js → 3-DvOZrxt7.js} +2 -2
  51. package/build/server/chunks/{3-Ds3b4DfT.js.map → 3-DvOZrxt7.js.map} +1 -1
  52. package/build/server/chunks/{6-DEbZkQEO.js → 6-DyP4lraz.js} +2 -2
  53. package/build/server/chunks/{6-DEbZkQEO.js.map → 6-DyP4lraz.js.map} +1 -1
  54. package/build/server/chunks/{7-BrQeR-CO.js → 7-CS9SCFyS.js} +2 -2
  55. package/build/server/chunks/{7-BrQeR-CO.js.map → 7-CS9SCFyS.js.map} +1 -1
  56. package/build/server/chunks/{8-e5TDwEpx.js → 8-Np7VqiBA.js} +2 -2
  57. package/build/server/chunks/{8-e5TDwEpx.js.map → 8-Np7VqiBA.js.map} +1 -1
  58. package/build/server/chunks/{9-1iqRqatJ.js → 9-C55g3WsT.js} +2 -2
  59. package/build/server/chunks/{9-1iqRqatJ.js.map → 9-C55g3WsT.js.map} +1 -1
  60. package/build/server/chunks/{_server.ts-C-W5J15L.js → _server.ts-CFX-S_8q.js} +20 -2
  61. package/build/server/chunks/_server.ts-CFX-S_8q.js.map +1 -0
  62. package/build/server/index.js +1 -1
  63. package/build/server/index.js.map +1 -1
  64. package/build/server/manifest.js +10 -10
  65. package/build/server/manifest.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/routes/api/notify/+server.ts +43 -2
  68. package/build/client/_app/immutable/chunks/DTGtOxE1.js.br +0 -0
  69. package/build/client/_app/immutable/chunks/DTGtOxE1.js.gz +0 -0
  70. package/build/client/_app/immutable/chunks/DfsJh23H.js.br +0 -0
  71. package/build/client/_app/immutable/chunks/DfsJh23H.js.gz +0 -0
  72. package/build/client/_app/immutable/chunks/DlSs5Yra.js +0 -3
  73. package/build/client/_app/immutable/chunks/DlSs5Yra.js.br +0 -0
  74. package/build/client/_app/immutable/chunks/DlSs5Yra.js.gz +0 -0
  75. package/build/client/_app/immutable/entry/app.B-sEFuLK.js.br +0 -0
  76. package/build/client/_app/immutable/entry/app.B-sEFuLK.js.gz +0 -0
  77. package/build/client/_app/immutable/entry/start.A2buqyYO.js +0 -1
  78. package/build/client/_app/immutable/entry/start.A2buqyYO.js.br +0 -2
  79. package/build/client/_app/immutable/entry/start.A2buqyYO.js.gz +0 -0
  80. package/build/client/_app/immutable/nodes/0.-0SstbRm.js.br +0 -0
  81. package/build/client/_app/immutable/nodes/0.-0SstbRm.js.gz +0 -0
  82. package/build/client/_app/immutable/nodes/1.BVLzPogE.js.br +0 -0
  83. package/build/client/_app/immutable/nodes/1.BVLzPogE.js.gz +0 -0
  84. package/build/client/_app/immutable/nodes/2.CiUyTQg5.js.br +0 -0
  85. package/build/client/_app/immutable/nodes/2.CiUyTQg5.js.gz +0 -0
  86. package/build/client/_app/immutable/nodes/3.C9vlOBU0.js.br +0 -0
  87. package/build/client/_app/immutable/nodes/3.C9vlOBU0.js.gz +0 -0
  88. package/build/client/_app/immutable/nodes/6.BSsUBbIT.js.br +0 -0
  89. package/build/client/_app/immutable/nodes/6.BSsUBbIT.js.gz +0 -0
  90. package/build/client/_app/immutable/nodes/7.BIQq9Yuz.js.br +0 -0
  91. package/build/client/_app/immutable/nodes/7.BIQq9Yuz.js.gz +0 -0
  92. package/build/client/_app/immutable/nodes/8.BU_sJ5_M.js.br +0 -0
  93. package/build/client/_app/immutable/nodes/8.BU_sJ5_M.js.gz +0 -0
  94. package/build/client/_app/immutable/nodes/9.C1vJI771.js.br +0 -0
  95. package/build/client/_app/immutable/nodes/9.C1vJI771.js.gz +0 -0
  96. package/build/server/chunks/_server.ts-C-W5J15L.js.map +0 -1
@@ -261,7 +261,12 @@ function adaptClaudeCodeEvent(cliArg, stdinData) {
261
261
  return createCommonEvent('claude-code', 'permission_notification', data);
262
262
 
263
263
  case 'elicitation_dialog':
264
- // Agent is presenting a question/dialog to the user
264
+ // Agent is presenting a question/dialog to the user. Forward
265
+ // choices/fields from stdin so extractElicitationChoices can
266
+ // surface CHOICE_N buttons (otherwise the question lands as a
267
+ // plain notification with no options).
268
+ if (Array.isArray(stdinData?.choices)) data.choices = stdinData.choices;
269
+ if (Array.isArray(stdinData?.fields)) data.fields = stdinData.fields;
265
270
  return createCommonEvent('claude-code', 'question', data);
266
271
 
267
272
  case 'idle_prompt':
@@ -284,6 +289,9 @@ function adaptClaudeCodeEvent(cliArg, stdinData) {
284
289
  data.tool = stdinData?.tool_name || process.env.CLAUDE_TOOL_NAME || 'Unknown';
285
290
  data.files = process.env.CLAUDE_FILE_PATHS || '';
286
291
  data.command = stdinData?.tool_input?.command || process.env.CLAUDE_COMMAND_LINE || '';
292
+ // tool_input is needed by handleAskUserQuestion to extract the
293
+ // question + options array. Forward it so handlers have access.
294
+ data.toolInput = stdinData?.tool_input || {};
287
295
  return createCommonEvent('claude-code', 'tool.before', data);
288
296
  }
289
297
 
@@ -513,6 +521,64 @@ async function processEvent(event) {
513
521
 
514
522
  function handleToolStart(event) {
515
523
  debugLog(`Tool starting: ${event.data.tool || 'unknown'}`);
524
+ // AskUserQuestion is a built-in tool whose answer we cannot intercept
525
+ // via any hook, but PreToolUse fires with the question + options in
526
+ // tool_input. Surface those on the phone (info-only) so the user can
527
+ // see what's being asked before walking back to the laptop.
528
+ if (event.data.tool === 'AskUserQuestion') {
529
+ handleAskUserQuestion(event);
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Render an AskUserQuestion call as a rich, info-only notification.
535
+ *
536
+ * The hook can't intercept the answer (PreToolUse for non-permission
537
+ * tools is informational), so the iOS notification carries the
538
+ * question + options for awareness; the user picks at the laptop.
539
+ * Tapping an option button on the phone fires a POST to /api/response
540
+ * which the server records under responseKind='info' (no routing
541
+ * back to Claude Code in PR-3 — PTY routing is a follow-up).
542
+ */
543
+ function handleAskUserQuestion(event) {
544
+ const d = event.data;
545
+ const toolInput = d.toolInput || {};
546
+ const extracted = extractAskUserQuestionOptions(toolInput);
547
+ if (!extracted) {
548
+ debugLog('AskUserQuestion did not yield extractable options — skipping');
549
+ return;
550
+ }
551
+
552
+ const category = categoryForOptionCount(extracted.options.length);
553
+ if (!category) {
554
+ debugLog(`AskUserQuestion option count outside lock-screen range — skipping`);
555
+ return;
556
+ }
557
+
558
+ const title = `${event.projectName} · Claude is asking`;
559
+ const subtitle = summarize(extracted.header || extracted.question, 70) || 'Awaiting answer';
560
+
561
+ const lines = [extracted.question || extracted.header || 'Choose an option:'];
562
+ const numbered = extracted.options.map((o, i) => `${i + 1}. ${o.label}`).join(' ');
563
+ lines.push('', numbered, '', 'Answer at your laptop.');
564
+ const body = lines.join('\n');
565
+
566
+ const requestId = Math.random().toString(36).substring(2, 15);
567
+
568
+ sendNotification(title, body, 'question', event.source, subtitle, {
569
+ notificationCategory: category,
570
+ options: extracted.options,
571
+ question: extracted.question,
572
+ requestId,
573
+ responseKind: 'info',
574
+ sessionId: d.sessionId,
575
+ toolInput,
576
+ toolName: 'AskUserQuestion',
577
+ // Need waitForResponse so /api/notify creates a pending_requests row
578
+ // (otherwise /api/decide/[id] won't find it when the user opens the
579
+ // Decide screen from the phone).
580
+ waitForResponse: true,
581
+ });
516
582
  }
517
583
 
518
584
  function handleToolEnd(event) {
@@ -523,19 +589,207 @@ function handleSessionStart(_event) {
523
589
  debugLog(`New session started: ${getSessionIdentifier()}`);
524
590
  }
525
591
 
592
+ /**
593
+ * Plan-mode approval options surfaced when Claude calls ExitPlanMode.
594
+ *
595
+ * iOS shows the first 3 + Open-in-Shooter on the lock screen (4 button
596
+ * cap); plan_keep is reachable via the Decide screen.
597
+ */
598
+ const PLAN_MODE_OPTIONS = [
599
+ { id: 'plan_auto', label: 'Auto Mode', hint: 'Bypass permissions for the rest of the session' },
600
+ {
601
+ id: 'plan_accept',
602
+ label: 'Accept Edits',
603
+ hint: 'Auto-accept file edits; still ask for risky operations',
604
+ },
605
+ { id: 'plan_review', label: 'Review Each', hint: 'Default — ask for each tool invocation' },
606
+ { id: 'plan_keep', label: 'Keep Planning', hint: 'Stay in plan mode without exiting' },
607
+ ];
608
+
609
+ /**
610
+ * Map plan_* decisions to the hookSpecificOutput shape Claude Code's
611
+ * PermissionRequest hook expects:
612
+ * plan_auto/accept/review → allow + updatedPermissions.setMode
613
+ * plan_keep → deny (stay in plan mode)
614
+ *
615
+ * Exported via test hook for unit testing without spinning up CC.
616
+ */
617
+ function planDecisionToHookResponse(decision) {
618
+ const modeMap = {
619
+ plan_auto: 'bypassPermissions',
620
+ plan_accept: 'acceptEdits',
621
+ plan_review: 'default',
622
+ };
623
+ if (decision === 'plan_keep') {
624
+ return {
625
+ hookSpecificOutput: {
626
+ hookEventName: 'PermissionRequest',
627
+ permissionDecision: 'deny',
628
+ permissionDecisionReason: 'User chose to keep planning',
629
+ },
630
+ };
631
+ }
632
+ const mode = modeMap[decision];
633
+ if (!mode) return null;
634
+ return {
635
+ hookSpecificOutput: {
636
+ hookEventName: 'PermissionRequest',
637
+ decision: {
638
+ behavior: 'allow',
639
+ updatedPermissions: [{ type: 'setMode', mode, destination: 'session' }],
640
+ },
641
+ },
642
+ };
643
+ }
644
+
645
+ /**
646
+ * Map a binary allow|deny decision to the hookSpecificOutput shape.
647
+ */
648
+ function binaryDecisionToHookResponse(decision, wsActive) {
649
+ return {
650
+ hookSpecificOutput: {
651
+ hookEventName: 'PermissionRequest',
652
+ permissionDecision: decision,
653
+ permissionDecisionReason: `User ${decision === 'allow' ? 'approved' : 'denied'} via ${wsActive ? 'WebSocket' : 'iPhone notification'}`,
654
+ },
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Detect whether this permission request is the plan-mode approval
660
+ * (ExitPlanMode tool). Plan-mode gets a richer 4-option notification
661
+ * instead of binary allow/deny.
662
+ */
663
+ function isPlanModePermission(d) {
664
+ return d.tool === 'ExitPlanMode' || d.toolName === 'ExitPlanMode';
665
+ }
666
+
667
+ /**
668
+ * Pick the right CHOICE_N notification category for an N-option push.
669
+ * Returns null when the count is outside the supported range (1 or
670
+ * >4) so the caller can fall back to info-only / open-in-app.
671
+ *
672
+ * iOS notification categories are pre-registered at app launch with
673
+ * fixed labels (see NotificationManager.swift); only counts 2/3/4 have
674
+ * dedicated registered categories.
675
+ */
676
+ function categoryForOptionCount(n) {
677
+ if (n === 2) return 'CLAUDE_CHOICE_2';
678
+ if (n === 3) return 'CLAUDE_CHOICE_3';
679
+ if (n === 4) return 'CLAUDE_CHOICE_4';
680
+ return null;
681
+ }
682
+
683
+ /**
684
+ * Extract OptionChoice[] from AskUserQuestion's tool_input.
685
+ *
686
+ * AskUserQuestion's schema:
687
+ * { questions: [{ question, header, multiSelect, options: [{label, description}] }] }
688
+ *
689
+ * Returns null when the tool_input doesn't look like a single-select
690
+ * multi-choice question with 2-4 options (the range we can render on
691
+ * iOS lock-screen buttons). The caller then falls through to the
692
+ * generic info notification.
693
+ */
694
+ function extractAskUserQuestionOptions(toolInput) {
695
+ const questions = Array.isArray(toolInput?.questions) ? toolInput.questions : [];
696
+ if (questions.length === 0) return null;
697
+ const q = questions[0];
698
+ const rawOpts = Array.isArray(q?.options) ? q.options : [];
699
+ if (rawOpts.length < 2) return null;
700
+
701
+ // Cap at 4 — iOS lock-screen actions max out there. Anything beyond
702
+ // is reachable via the Decide screen which has no cap.
703
+ const trimmed = rawOpts.slice(0, 4);
704
+ const options = trimmed.map((o, i) => {
705
+ const label = typeof o?.label === 'string' && o.label.length > 0 ? o.label : `Option ${i + 1}`;
706
+ const hint =
707
+ typeof o?.description === 'string' && o.description.length > 0 ? o.description : undefined;
708
+ return hint ? { id: `option_${i + 1}`, label, hint } : { id: `option_${i + 1}`, label };
709
+ });
710
+
711
+ return {
712
+ options,
713
+ question: typeof q.question === 'string' ? q.question : '',
714
+ header: typeof q.header === 'string' ? q.header : '',
715
+ };
716
+ }
717
+
718
+ /**
719
+ * Extract choices from a Notification hook payload of type
720
+ * `elicitation_dialog`. MCP elicitation forms put a select field's
721
+ * choices in `fields[].choices` per the elicitation spec; some
722
+ * implementations also surface a flatter `choices` array directly on
723
+ * the notification data. We try both shapes.
724
+ *
725
+ * Returns null when no select-field with 2-4 choices is found.
726
+ */
727
+ function extractElicitationChoices(data) {
728
+ const directChoices = Array.isArray(data?.choices) ? data.choices : null;
729
+ const fields = Array.isArray(data?.fields) ? data.fields : [];
730
+ const selectField = fields.find(
731
+ (f) => f && f.type === 'select' && Array.isArray(f.choices) && f.choices.length >= 2
732
+ );
733
+
734
+ const choices =
735
+ directChoices && directChoices.length >= 2 ? directChoices : (selectField?.choices ?? null);
736
+ if (!choices || choices.length < 2) return null;
737
+
738
+ const trimmed = choices.slice(0, 4);
739
+ const options = trimmed.map((c, i) => {
740
+ const label = typeof c === 'string' ? c : c?.label || c?.value || `Option ${i + 1}`;
741
+ return { id: `option_${i + 1}`, label };
742
+ });
743
+
744
+ return {
745
+ options,
746
+ fieldName: selectField?.name ?? null,
747
+ };
748
+ }
749
+
750
+ /**
751
+ * Build the notification body for a plan-mode approval. The plan
752
+ * content itself sits in body + question so the iOS Decide screen can
753
+ * show it; the body also lists the numbered options for lock-screen
754
+ * users who don't open the app.
755
+ */
756
+ function buildPlanModeNotification(event) {
757
+ const d = event.data;
758
+ const toolInput = d.toolInput || {};
759
+ const plan = typeof toolInput.plan === 'string' ? toolInput.plan : '';
760
+ const planSummary = summarize(plan, 300) || 'Plan ready for approval';
761
+
762
+ const title = `${event.projectName} · Plan ready`;
763
+ const subtitle = summarize(plan, 70) || 'Review and choose how to proceed';
764
+ const body = [
765
+ planSummary,
766
+ '',
767
+ '1. Auto Mode 2. Accept Edits 3. Review Each 4. Keep Planning',
768
+ ].join('\n');
769
+
770
+ return { title, subtitle, body, question: plan };
771
+ }
772
+
526
773
  /**
527
774
  * Handle permission events (agent needs user to approve a tool)
528
775
  *
529
776
  * Builds a rich notification with tool name + details when available,
530
777
  * falls back to the message text when tool details aren't available.
531
778
  * Content is identical between Claude Code and OpenCode.
779
+ *
780
+ * Plan-mode (ExitPlanMode tool) gets a special 4-option flow instead
781
+ * of binary allow/deny — see PLAN_MODE_OPTIONS + planDecisionToHookResponse.
532
782
  */
533
783
  async function handlePermission(event) {
534
784
  const d = event.data;
535
785
  debugLog(`Permission event: tool=${d.tool}, message=${d.message}`);
536
786
 
787
+ const planMode = isPlanModePermission(d);
788
+
537
789
  const ctx = getSessionContext(d.sessionId);
538
- const { title, subtitle, body } = buildPermissionNotification(event, ctx);
790
+ const { title, subtitle, body } = planMode
791
+ ? buildPlanModeNotification(event)
792
+ : buildPermissionNotification(event, ctx);
539
793
 
540
794
  // Check if WebSocket clients are connected — if so, the events channel
541
795
  // will broadcast the permission-requested event and we skip the push notification
@@ -544,12 +798,20 @@ async function handlePermission(event) {
544
798
  // For Claude Code PermissionRequest: block and poll for iPhone response
545
799
  if (IS_CLAUDE_CODE && event.source === 'claude-code') {
546
800
  const requestId = Math.random().toString(36).substring(2, 15);
547
- debugLog(`Starting bidirectional permission flow (requestId: ${requestId})`);
801
+ debugLog(
802
+ `Starting bidirectional permission flow (requestId: ${requestId}, planMode: ${planMode})`
803
+ );
804
+
805
+ const extras = { skipPush: wsActive, subtitle };
806
+ if (planMode) {
807
+ extras.notificationCategory = 'CLAUDE_PLAN_APPROVAL';
808
+ extras.options = PLAN_MODE_OPTIONS;
809
+ extras.question =
810
+ typeof d.toolInput?.plan === 'string' ? d.toolInput.plan : 'Approve plan and proceed?';
811
+ extras.responseKind = 'hook';
812
+ }
548
813
 
549
- let result;
550
814
  if (wsActive) {
551
- // WebSocket clients connected — skip push notification, but still register
552
- // the pending request on the server so polling can find it.
553
815
  debugLog(`[Notifier] WebSocket clients connected, skipping push notification`);
554
816
  if (IS_CLAUDE_CODE) {
555
817
  console.error(`\n=== WEBSOCKET ACTIVE — SKIPPING PUSH [${requestId}] ===`);
@@ -557,42 +819,32 @@ async function handlePermission(event) {
557
819
  console.error(`Message: ${body}`);
558
820
  console.error(`=== REGISTERING REQUEST & POLLING VIA WEBSOCKET CHANNEL ===\n`);
559
821
  }
560
-
561
- // Register the pending request but skip the actual push notification —
562
- // the events channel will broadcast the permission to connected clients.
563
- result = await sendNotificationAndPoll(
564
- title,
565
- body,
566
- 'permission',
567
- event.source,
568
- requestId,
569
- d,
570
- { skipPush: true, subtitle }
571
- );
572
- } else {
573
- // No WebSocket clients — send push notification and poll
574
- result = await sendNotificationAndPoll(
575
- title,
576
- body,
577
- 'permission',
578
- event.source,
579
- requestId,
580
- d,
581
- { subtitle }
582
- );
583
822
  }
584
823
 
824
+ const result = await sendNotificationAndPoll(
825
+ title,
826
+ body,
827
+ 'permission',
828
+ event.source,
829
+ requestId,
830
+ d,
831
+ extras
832
+ );
833
+
585
834
  if (result && result.decision) {
586
- const hookResponse = {
587
- hookSpecificOutput: {
588
- hookEventName: 'PermissionRequest',
589
- permissionDecision: result.decision,
590
- permissionDecisionReason: `User ${result.decision === 'allow' ? 'approved' : 'denied'} via ${wsActive ? 'WebSocket' : 'iPhone notification'}`,
591
- },
592
- };
593
- // Write decision to stdout for Claude Code to read
594
- process.stdout.write(JSON.stringify(hookResponse));
595
- debugLog(`Wrote hook decision to stdout: ${result.decision}`);
835
+ // Plan-mode decisions (plan_auto/accept/review/keep) need the
836
+ // richer hookSpecificOutput shape with updatedPermissions.setMode.
837
+ // Binary allow/deny falls through the legacy path.
838
+ const hookResponse = planMode
839
+ ? planDecisionToHookResponse(result.decision)
840
+ : binaryDecisionToHookResponse(result.decision, wsActive);
841
+
842
+ if (hookResponse) {
843
+ process.stdout.write(JSON.stringify(hookResponse));
844
+ debugLog(`Wrote hook decision to stdout: ${result.decision}`);
845
+ } else {
846
+ debugLog(`Unknown decision shape '${result.decision}' — falling through to local dialog`);
847
+ }
596
848
  } else {
597
849
  debugLog('No response received - falling through to local permission dialog');
598
850
  // Output nothing → Claude Code shows normal permission dialog
@@ -608,6 +860,11 @@ async function handlePermission(event) {
608
860
  }
609
861
  }
610
862
 
863
+ // Pure helpers above are also re-exported alongside OpenCodePlugin at
864
+ // the bottom of this file for unit testing. The single module.exports
865
+ // block lives at the end of the file (see "Exports and Main Execution"
866
+ // section).
867
+
611
868
  /**
612
869
  * Handle permission_notification events (Notification hook with permission_prompt type).
613
870
  *
@@ -644,7 +901,26 @@ function handleQuestion(event) {
644
901
  const ctx = getSessionContext(d.sessionId);
645
902
  const { title, subtitle, body } = buildQuestionNotification(event, ctx);
646
903
 
647
- sendNotification(title, body, 'question', event.source, subtitle);
904
+ // If the elicitation payload has dynamic choices (MCP select field),
905
+ // surface them as a CHOICE_N category so the iOS Decide screen can
906
+ // render proper option buttons. Still info-only — the Notification
907
+ // hook can't return a decision, so any user tap on phone is recorded
908
+ // for awareness only.
909
+ const choiceData = extractElicitationChoices(d);
910
+ const extras = { sessionId: d.sessionId };
911
+ if (choiceData) {
912
+ const category = categoryForOptionCount(choiceData.options.length);
913
+ if (category) {
914
+ extras.notificationCategory = category;
915
+ extras.options = choiceData.options;
916
+ extras.question = d.message || d.title || '';
917
+ extras.responseKind = 'info';
918
+ extras.waitForResponse = true;
919
+ debugLog(`Elicitation choice surfaced: ${choiceData.options.length} options → ${category}`);
920
+ }
921
+ }
922
+
923
+ sendNotification(title, body, 'question', event.source, subtitle, extras);
648
924
  }
649
925
 
650
926
  /**
@@ -835,7 +1111,10 @@ function readSessionLines(sessionId, cwd) {
835
1111
  const buf = Buffer.alloc(stat.size);
836
1112
  fs.readSync(fd, buf, 0, stat.size, 0);
837
1113
  fs.closeSync(fd);
838
- const all = buf.toString('utf-8').split('\n').filter((l) => l.trim());
1114
+ const all = buf
1115
+ .toString('utf-8')
1116
+ .split('\n')
1117
+ .filter((l) => l.trim());
839
1118
  firstLines = all;
840
1119
  lastLines = all;
841
1120
  } else {
@@ -846,9 +1125,15 @@ function readSessionLines(sessionId, cwd) {
846
1125
  fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
847
1126
  fs.closeSync(fd);
848
1127
 
849
- firstLines = headBuf.toString('utf-8').split('\n').filter((l) => l.trim());
1128
+ firstLines = headBuf
1129
+ .toString('utf-8')
1130
+ .split('\n')
1131
+ .filter((l) => l.trim());
850
1132
  if (firstLines.length > 0) firstLines.pop(); // drop possibly-partial last line
851
- lastLines = tailBuf.toString('utf-8').split('\n').filter((l) => l.trim());
1133
+ lastLines = tailBuf
1134
+ .toString('utf-8')
1135
+ .split('\n')
1136
+ .filter((l) => l.trim());
852
1137
  if (lastLines.length > 0) lastLines.shift(); // drop possibly-partial first line
853
1138
  }
854
1139
 
@@ -1081,9 +1366,7 @@ function buildPermissionNotification(event, ctx = {}) {
1081
1366
  }
1082
1367
 
1083
1368
  // No tool name (Notification permission_prompt path).
1084
- const subtitle = goal
1085
- ? `Goal: ${summarize(goal, 70)}`
1086
- : summarize(message) || 'Permission';
1369
+ const subtitle = goal ? `Goal: ${summarize(goal, 70)}` : summarize(message) || 'Permission';
1087
1370
 
1088
1371
  if (message) {
1089
1372
  const lines = [message];
@@ -1176,8 +1459,47 @@ function sendNotificationAndPoll(
1176
1459
  source,
1177
1460
  requestId,
1178
1461
  eventData,
1179
- { skipPush = false, subtitle = '' } = {}
1462
+ {
1463
+ skipPush = false,
1464
+ subtitle = '',
1465
+ // Dynamic-options extras forwarded to /api/notify body so the server
1466
+ // can persist them on the pending_requests row + set the right APNs
1467
+ // category. Undefined → server falls back to the binary CLAUDE_PERMISSION
1468
+ // flow (preserves legacy behavior).
1469
+ notificationCategory,
1470
+ question,
1471
+ options,
1472
+ responseKind,
1473
+ } = {}
1180
1474
  ) {
1475
+ // Common body shared between the skipPush (register-only) and the
1476
+ // full push paths. Splitting reduces drift between the two.
1477
+ const buildNotifyBody = (timestamp, finalBody) => ({
1478
+ title,
1479
+ ...(subtitle ? { subtitle } : {}),
1480
+ message: finalBody,
1481
+ waitForResponse: true,
1482
+ ...(skipPush ? { skipPush: true } : {}),
1483
+ ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
1484
+ ...(notificationCategory && { notificationCategory }),
1485
+ ...(question !== undefined && { question }),
1486
+ ...(options && { options }),
1487
+ ...(responseKind && { responseKind }),
1488
+ data: {
1489
+ category,
1490
+ project: getProjectName(),
1491
+ timestamp,
1492
+ requestId,
1493
+ clientTimestamp: timestamp,
1494
+ source: 'shooter-completion-detector',
1495
+ environment: USE_LOCAL ? 'local' : 'remote',
1496
+ runtime: source,
1497
+ toolName: eventData.tool || '',
1498
+ toolInput: eventData.toolInput || {},
1499
+ sessionId: eventData.sessionId || '',
1500
+ },
1501
+ });
1502
+
1181
1503
  return new Promise((resolve) => {
1182
1504
  const timestamp = new Date().toISOString();
1183
1505
 
@@ -1194,27 +1516,7 @@ function sendNotificationAndPoll(
1194
1516
  // Register the pending request on the server so polling finds it.
1195
1517
  // We still need to POST with waitForResponse so the server creates
1196
1518
  // the pending-request entry, but we mark it as ws-only.
1197
- const registerPayload = JSON.stringify({
1198
- title,
1199
- ...(subtitle ? { subtitle } : {}),
1200
- message: finalBody,
1201
- waitForResponse: true,
1202
- skipPush: true,
1203
- ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
1204
- data: {
1205
- category,
1206
- project: getProjectName(),
1207
- timestamp,
1208
- requestId,
1209
- clientTimestamp: timestamp,
1210
- source: 'shooter-completion-detector',
1211
- environment: USE_LOCAL ? 'local' : 'remote',
1212
- runtime: source,
1213
- toolName: eventData.tool || '',
1214
- toolInput: eventData.toolInput || {},
1215
- sessionId: eventData.sessionId || '',
1216
- },
1217
- });
1519
+ const registerPayload = JSON.stringify(buildNotifyBody(timestamp, finalBody));
1218
1520
 
1219
1521
  const registerOptions = {
1220
1522
  method: 'POST',
@@ -1263,26 +1565,7 @@ function sendNotificationAndPoll(
1263
1565
 
1264
1566
  debugLog(`Sending bidirectional notification: "${title}" (requestId: ${requestId})`);
1265
1567
 
1266
- const payload = JSON.stringify({
1267
- title,
1268
- ...(subtitle ? { subtitle } : {}),
1269
- message: finalBody,
1270
- waitForResponse: true,
1271
- ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
1272
- data: {
1273
- category,
1274
- project: getProjectName(),
1275
- timestamp,
1276
- requestId,
1277
- clientTimestamp: timestamp,
1278
- source: 'shooter-completion-detector',
1279
- environment: USE_LOCAL ? 'local' : 'remote',
1280
- runtime: source,
1281
- toolName: eventData.tool || '',
1282
- toolInput: eventData.toolInput || {},
1283
- sessionId: eventData.sessionId || '',
1284
- },
1285
- });
1568
+ const payload = JSON.stringify(buildNotifyBody(timestamp, finalBody));
1286
1569
 
1287
1570
  const options = {
1288
1571
  method: 'POST',
@@ -1420,8 +1703,22 @@ function startPolling(requestId, resolve) {
1420
1703
  }, POLL_INTERVAL);
1421
1704
  }
1422
1705
 
1423
- function sendNotification(title, body, category = 'completion', source = RUNTIME, subtitle = '') {
1424
- const requestId = Math.random().toString(36).substring(2, 15);
1706
+ function sendNotification(
1707
+ title,
1708
+ body,
1709
+ category = 'completion',
1710
+ source = RUNTIME,
1711
+ subtitle = '',
1712
+ // PR-3: extras carries the dynamic-options fields (notificationCategory,
1713
+ // question, options, responseKind) for AskUserQuestion / elicitation
1714
+ // pushes that need to render proper action buttons + populate the
1715
+ // /api/decide/[id] payload. Optionally setting waitForResponse=true
1716
+ // tells the server to persist a pending_requests row even though this
1717
+ // is a fire-and-forget call (no polling) — needed so the iOS Decide
1718
+ // screen can fetch the question + options afterward.
1719
+ extras = {}
1720
+ ) {
1721
+ const requestId = extras.requestId || Math.random().toString(36).substring(2, 15);
1425
1722
  const timestamp = new Date().toISOString();
1426
1723
 
1427
1724
  // Title flows through unchanged; runtime/env metadata is appended as a low-weight
@@ -1437,6 +1734,11 @@ function sendNotification(title, body, category = 'completion', source = RUNTIME
1437
1734
  ...(subtitle ? { subtitle } : {}),
1438
1735
  message: finalBody,
1439
1736
  ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
1737
+ ...(extras.waitForResponse && { waitForResponse: true }),
1738
+ ...(extras.notificationCategory && { notificationCategory: extras.notificationCategory }),
1739
+ ...(extras.question !== undefined && { question: extras.question }),
1740
+ ...(extras.options && { options: extras.options }),
1741
+ ...(extras.responseKind && { responseKind: extras.responseKind }),
1440
1742
  data: {
1441
1743
  category,
1442
1744
  project: getProjectName(),
@@ -1446,6 +1748,12 @@ function sendNotification(title, body, category = 'completion', source = RUNTIME
1446
1748
  source: 'shooter-completion-detector',
1447
1749
  environment: USE_LOCAL ? 'local' : 'remote',
1448
1750
  runtime: source,
1751
+ // Echo toolName/toolInput/sessionId when the caller knows them so
1752
+ // the server-side row + Decide screen have full context (matches
1753
+ // the eventData spread in sendNotificationAndPoll).
1754
+ ...(extras.toolName && { toolName: extras.toolName }),
1755
+ ...(extras.toolInput && { toolInput: extras.toolInput }),
1756
+ ...(extras.sessionId && { sessionId: extras.sessionId }),
1449
1757
  },
1450
1758
  });
1451
1759
 
@@ -1643,11 +1951,26 @@ const OpenCodePlugin = async (ctx) => {
1643
1951
  // Exports and Main Execution
1644
1952
  // ============================================
1645
1953
 
1646
- // Export for OpenCode plugin system
1954
+ // Export for OpenCode plugin system + unit tests.
1955
+ //
1956
+ // The default export remains OpenCodePlugin so the OpenCode plugin
1957
+ // loader (which does `require(notifier.cjs)()`) keeps working. Pure
1958
+ // helpers (plan-mode routing, etc.) are attached as named properties
1959
+ // so tests/ can require them without spawning the full hook script.
1647
1960
  if (typeof module !== 'undefined' && module.exports) {
1648
1961
  module.exports = OpenCodePlugin;
1649
1962
  module.exports.OpenCodePlugin = OpenCodePlugin;
1650
1963
  module.exports.ShooterNotifier = OpenCodePlugin;
1964
+ // Pure-function exports for unit tests (tests/plan-mode-routing.test.cjs).
1965
+ module.exports.PLAN_MODE_OPTIONS = PLAN_MODE_OPTIONS;
1966
+ module.exports.binaryDecisionToHookResponse = binaryDecisionToHookResponse;
1967
+ module.exports.isPlanModePermission = isPlanModePermission;
1968
+ module.exports.planDecisionToHookResponse = planDecisionToHookResponse;
1969
+ // PR-3 helpers (tests/dynamic-options-extraction.test.cjs).
1970
+ module.exports.adaptClaudeCodeEvent = adaptClaudeCodeEvent;
1971
+ module.exports.categoryForOptionCount = categoryForOptionCount;
1972
+ module.exports.extractAskUserQuestionOptions = extractAskUserQuestionOptions;
1973
+ module.exports.extractElicitationChoices = extractElicitationChoices;
1651
1974
  }
1652
1975
 
1653
1976
  // Run main() when called directly from CLI (Claude Code)
@@ -1 +1 @@
1
- import{s as e}from"./DlSs5Yra.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};
1
+ import{s as e}from"./D8sAtVC-.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};
@@ -1 +1 @@
1
- import{s as r,p as e}from"./DlSs5Yra.js";const a={get params(){return e.params},get url(){return e.url}};r.updated.check;const s=a;export{s as p};
1
+ import{s as r,p as e}from"./D8sAtVC-.js";const a={get params(){return e.params},get url(){return e.url}};r.updated.check;const s=a;export{s as p};