@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.
- package/.claude/hooks/notifier.cjs +414 -91
- package/build/client/_app/immutable/chunks/{DfsJh23H.js → Bfg0k16X.js} +1 -1
- package/build/client/_app/immutable/chunks/Bfg0k16X.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bfg0k16X.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{DTGtOxE1.js → CTchCNsn.js} +1 -1
- package/build/client/_app/immutable/chunks/CTchCNsn.js.br +0 -0
- package/build/client/_app/immutable/chunks/CTchCNsn.js.gz +0 -0
- package/build/client/_app/immutable/chunks/D8sAtVC-.js +3 -0
- package/build/client/_app/immutable/chunks/D8sAtVC-.js.br +0 -0
- package/build/client/_app/immutable/chunks/D8sAtVC-.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.B-sEFuLK.js → app.rri2K7zq.js} +2 -2
- package/build/client/_app/immutable/entry/app.rri2K7zq.js.br +0 -0
- package/build/client/_app/immutable/entry/app.rri2K7zq.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.BRqv-XKD.js +1 -0
- package/build/client/_app/immutable/entry/start.BRqv-XKD.js.br +2 -0
- package/build/client/_app/immutable/entry/start.BRqv-XKD.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.-0SstbRm.js → 0.DoDuvMbN.js} +1 -1
- package/build/client/_app/immutable/nodes/0.DoDuvMbN.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.DoDuvMbN.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.BVLzPogE.js → 1.DZ0g9EOo.js} +1 -1
- package/build/client/_app/immutable/nodes/1.DZ0g9EOo.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.DZ0g9EOo.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{2.CiUyTQg5.js → 2.COrzaySY.js} +1 -1
- package/build/client/_app/immutable/nodes/2.COrzaySY.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.COrzaySY.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.C9vlOBU0.js → 3.sGijgjBd.js} +1 -1
- package/build/client/_app/immutable/nodes/3.sGijgjBd.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.sGijgjBd.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{6.BSsUBbIT.js → 6.DfVtwT6x.js} +1 -1
- package/build/client/_app/immutable/nodes/6.DfVtwT6x.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.DfVtwT6x.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.BIQq9Yuz.js → 7.DFkQ9bmS.js} +1 -1
- package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{8.BU_sJ5_M.js → 8.BkFDeNg9.js} +1 -1
- package/build/client/_app/immutable/nodes/8.BkFDeNg9.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.BkFDeNg9.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{9.C1vJI771.js → 9.g02G0hlL.js} +1 -1
- package/build/client/_app/immutable/nodes/9.g02G0hlL.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.g02G0hlL.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/server/chunks/{0-DgzcVTc0.js → 0-Cs7l_or5.js} +2 -2
- package/build/server/chunks/{0-DgzcVTc0.js.map → 0-Cs7l_or5.js.map} +1 -1
- package/build/server/chunks/{1-iMvE8O_M.js → 1-DrEnteaQ.js} +2 -2
- package/build/server/chunks/{1-iMvE8O_M.js.map → 1-DrEnteaQ.js.map} +1 -1
- package/build/server/chunks/{2-BJrmwHii.js → 2-BxYq6tAd.js} +2 -2
- package/build/server/chunks/{2-BJrmwHii.js.map → 2-BxYq6tAd.js.map} +1 -1
- package/build/server/chunks/{3-Ds3b4DfT.js → 3-DvOZrxt7.js} +2 -2
- package/build/server/chunks/{3-Ds3b4DfT.js.map → 3-DvOZrxt7.js.map} +1 -1
- package/build/server/chunks/{6-DEbZkQEO.js → 6-DyP4lraz.js} +2 -2
- package/build/server/chunks/{6-DEbZkQEO.js.map → 6-DyP4lraz.js.map} +1 -1
- package/build/server/chunks/{7-BrQeR-CO.js → 7-CS9SCFyS.js} +2 -2
- package/build/server/chunks/{7-BrQeR-CO.js.map → 7-CS9SCFyS.js.map} +1 -1
- package/build/server/chunks/{8-e5TDwEpx.js → 8-Np7VqiBA.js} +2 -2
- package/build/server/chunks/{8-e5TDwEpx.js.map → 8-Np7VqiBA.js.map} +1 -1
- package/build/server/chunks/{9-1iqRqatJ.js → 9-C55g3WsT.js} +2 -2
- package/build/server/chunks/{9-1iqRqatJ.js.map → 9-C55g3WsT.js.map} +1 -1
- package/build/server/chunks/{_server.ts-C-W5J15L.js → _server.ts-CFX-S_8q.js} +20 -2
- package/build/server/chunks/_server.ts-CFX-S_8q.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +10 -10
- package/build/server/manifest.js.map +1 -1
- package/package.json +2 -2
- package/src/routes/api/notify/+server.ts +43 -2
- package/build/client/_app/immutable/chunks/DTGtOxE1.js.br +0 -0
- package/build/client/_app/immutable/chunks/DTGtOxE1.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DfsJh23H.js.br +0 -0
- package/build/client/_app/immutable/chunks/DfsJh23H.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DlSs5Yra.js +0 -3
- package/build/client/_app/immutable/chunks/DlSs5Yra.js.br +0 -0
- package/build/client/_app/immutable/chunks/DlSs5Yra.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.B-sEFuLK.js.br +0 -0
- package/build/client/_app/immutable/entry/app.B-sEFuLK.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.A2buqyYO.js +0 -1
- package/build/client/_app/immutable/entry/start.A2buqyYO.js.br +0 -2
- package/build/client/_app/immutable/entry/start.A2buqyYO.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.-0SstbRm.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.-0SstbRm.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.BVLzPogE.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.BVLzPogE.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.CiUyTQg5.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.CiUyTQg5.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.C9vlOBU0.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.C9vlOBU0.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.BSsUBbIT.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.BSsUBbIT.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.BIQq9Yuz.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.BIQq9Yuz.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.BU_sJ5_M.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.BU_sJ5_M.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.C1vJI771.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.C1vJI771.js.gz +0 -0
- 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 } =
|
|
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(
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
1424
|
-
|
|
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"./
|
|
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};
|
|
Binary file
|
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{s as r,p as e}from"./
|
|
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};
|
|
Binary file
|
|
Binary file
|