@juspay/shooter 1.8.0 → 1.9.1

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 (125) hide show
  1. package/.claude/hooks/notifier.cjs +490 -136
  2. package/bin/shooter.cjs +50 -14
  3. package/build/client/_app/immutable/chunks/3EfvnCrr.js +1 -0
  4. package/build/client/_app/immutable/chunks/3EfvnCrr.js.br +0 -0
  5. package/build/client/_app/immutable/chunks/3EfvnCrr.js.gz +0 -0
  6. package/build/client/_app/immutable/chunks/{B6b4w6vf.js → DaomQix1.js} +1 -1
  7. package/build/client/_app/immutable/chunks/DaomQix1.js.br +0 -0
  8. package/build/client/_app/immutable/chunks/{B6b4w6vf.js.gz → DaomQix1.js.gz} +0 -0
  9. package/build/client/_app/immutable/chunks/Dfn9ME_a.js +3 -0
  10. package/build/client/_app/immutable/chunks/Dfn9ME_a.js.br +0 -0
  11. package/build/client/_app/immutable/chunks/Dfn9ME_a.js.gz +0 -0
  12. package/build/client/_app/immutable/chunks/{C7SeOWDG.js → DuaEmHXZ.js} +1 -1
  13. package/build/client/_app/immutable/chunks/DuaEmHXZ.js.br +0 -0
  14. package/build/client/_app/immutable/chunks/DuaEmHXZ.js.gz +0 -0
  15. package/build/client/_app/immutable/entry/{app.CP7226A7.js → app.C23A4_LP.js} +2 -2
  16. package/build/client/_app/immutable/entry/app.C23A4_LP.js.br +0 -0
  17. package/build/client/_app/immutable/entry/app.C23A4_LP.js.gz +0 -0
  18. package/build/client/_app/immutable/entry/start.Dzoby1AT.js +1 -0
  19. package/build/client/_app/immutable/entry/start.Dzoby1AT.js.br +2 -0
  20. package/build/client/_app/immutable/entry/start.Dzoby1AT.js.gz +0 -0
  21. package/build/client/_app/immutable/nodes/{0.DwU44ZAj.js → 0.BgwfOZTV.js} +1 -1
  22. package/build/client/_app/immutable/nodes/0.BgwfOZTV.js.br +0 -0
  23. package/build/client/_app/immutable/nodes/0.BgwfOZTV.js.gz +0 -0
  24. package/build/client/_app/immutable/nodes/{1.CChG-n6d.js → 1.DunuP0ec.js} +1 -1
  25. package/build/client/_app/immutable/nodes/1.DunuP0ec.js.br +0 -0
  26. package/build/client/_app/immutable/nodes/1.DunuP0ec.js.gz +0 -0
  27. package/build/client/_app/immutable/nodes/{2.CzexDbwp.js → 2.dWYUfDXP.js} +2 -2
  28. package/build/client/_app/immutable/nodes/2.dWYUfDXP.js.br +0 -0
  29. package/build/client/_app/immutable/nodes/2.dWYUfDXP.js.gz +0 -0
  30. package/build/client/_app/immutable/nodes/{3.DC3WghxB.js → 3.rBWJMWFr.js} +3 -3
  31. package/build/client/_app/immutable/nodes/3.rBWJMWFr.js.br +0 -0
  32. package/build/client/_app/immutable/nodes/3.rBWJMWFr.js.gz +0 -0
  33. package/build/client/_app/immutable/nodes/5.BN2SM61w.js +1 -0
  34. package/build/client/_app/immutable/nodes/5.BN2SM61w.js.br +0 -0
  35. package/build/client/_app/immutable/nodes/5.BN2SM61w.js.gz +0 -0
  36. package/build/client/_app/immutable/nodes/{6.C4aXlZQd.js → 6.DDMVIiVk.js} +1 -1
  37. package/build/client/_app/immutable/nodes/6.DDMVIiVk.js.br +0 -0
  38. package/build/client/_app/immutable/nodes/6.DDMVIiVk.js.gz +0 -0
  39. package/build/client/_app/immutable/nodes/{7.DfniCleW.js → 7.DijtBcpt.js} +1 -1
  40. package/build/client/_app/immutable/nodes/7.DijtBcpt.js.br +0 -0
  41. package/build/client/_app/immutable/nodes/7.DijtBcpt.js.gz +0 -0
  42. package/build/client/_app/immutable/nodes/{8.D4AzZWcq.js → 8.Y_8EIs9h.js} +1 -1
  43. package/build/client/_app/immutable/nodes/8.Y_8EIs9h.js.br +0 -0
  44. package/build/client/_app/immutable/nodes/8.Y_8EIs9h.js.gz +0 -0
  45. package/build/client/_app/immutable/nodes/{9.gV8oJWv_.js → 9.BoJIHqox.js} +1 -1
  46. package/build/client/_app/immutable/nodes/9.BoJIHqox.js.br +0 -0
  47. package/build/client/_app/immutable/nodes/9.BoJIHqox.js.gz +0 -0
  48. package/build/client/_app/version.json +1 -1
  49. package/build/client/_app/version.json.br +0 -0
  50. package/build/client/_app/version.json.gz +0 -0
  51. package/build/server/chunks/{0-XyVDlEyN.js → 0-0CZFKlE-.js} +2 -2
  52. package/build/server/chunks/{0-XyVDlEyN.js.map → 0-0CZFKlE-.js.map} +1 -1
  53. package/build/server/chunks/{1-C3vZx9QL.js → 1-DLzdA7q9.js} +2 -2
  54. package/build/server/chunks/{1-C3vZx9QL.js.map → 1-DLzdA7q9.js.map} +1 -1
  55. package/build/server/chunks/{2-LWO3Q9-s.js → 2-DssNCbHE.js} +2 -2
  56. package/build/server/chunks/{2-LWO3Q9-s.js.map → 2-DssNCbHE.js.map} +1 -1
  57. package/build/server/chunks/{3-3WzO52IA.js → 3-xYdrp1JC.js} +2 -2
  58. package/build/server/chunks/{3-3WzO52IA.js.map → 3-xYdrp1JC.js.map} +1 -1
  59. package/build/server/chunks/{5-Bj49x3to.js → 5-DRhcUdp_.js} +2 -2
  60. package/build/server/chunks/{5-Bj49x3to.js.map → 5-DRhcUdp_.js.map} +1 -1
  61. package/build/server/chunks/{6-DjPIWYcj.js → 6-DpmjM0Zz.js} +2 -2
  62. package/build/server/chunks/{6-DjPIWYcj.js.map → 6-DpmjM0Zz.js.map} +1 -1
  63. package/build/server/chunks/{7-DF5FUXhP.js → 7-fZr6V0jx.js} +2 -2
  64. package/build/server/chunks/{7-DF5FUXhP.js.map → 7-fZr6V0jx.js.map} +1 -1
  65. package/build/server/chunks/{8-CejJgM0l.js → 8-C92Y2pM_.js} +2 -2
  66. package/build/server/chunks/{8-CejJgM0l.js.map → 8-C92Y2pM_.js.map} +1 -1
  67. package/build/server/chunks/{9-D1YMozmH.js → 9-xiLWXynw.js} +2 -2
  68. package/build/server/chunks/{9-D1YMozmH.js.map → 9-xiLWXynw.js.map} +1 -1
  69. package/build/server/chunks/{_server.ts-G8OeADGj.js → _server.ts-BMcbwZ2r.js} +8 -3
  70. package/build/server/chunks/_server.ts-BMcbwZ2r.js.map +1 -0
  71. package/build/server/chunks/{_server.ts-A9_tRR-K.js → _server.ts-D-vgx5UZ.js} +5 -3
  72. package/build/server/chunks/{_server.ts-A9_tRR-K.js.map → _server.ts-D-vgx5UZ.js.map} +1 -1
  73. package/build/server/chunks/library-apns-Dl3iRE2h.js +157 -0
  74. package/build/server/chunks/library-apns-Dl3iRE2h.js.map +1 -0
  75. package/build/server/index.js +1 -1
  76. package/build/server/index.js.map +1 -1
  77. package/build/server/manifest.js +12 -12
  78. package/build/server/manifest.js.map +1 -1
  79. package/package.json +1 -2
  80. package/server.ts +55 -3
  81. package/src/lib/modules/client/activity/summarizer.ts +1 -1
  82. package/src/lib/modules/client/dashboard/summarizer.ts +1 -2
  83. package/src/lib/modules/client/neurolink/cdn.ts +6 -0
  84. package/src/lib/modules/client/terminal/LaunchSheet.svelte +5 -12
  85. package/src/lib/modules/server/apn/library-apns.ts +138 -77
  86. package/src/lib/modules/server/fcm/fcm-service.ts +1 -0
  87. package/src/lib/types/apn.ts +11 -0
  88. package/src/routes/api/notify/+server.ts +2 -0
  89. package/src/routes/neurolink/+page.svelte +4 -6
  90. package/build/client/_app/immutable/chunks/B6b4w6vf.js.br +0 -0
  91. package/build/client/_app/immutable/chunks/BEa4nlMF.js +0 -3
  92. package/build/client/_app/immutable/chunks/BEa4nlMF.js.br +0 -0
  93. package/build/client/_app/immutable/chunks/BEa4nlMF.js.gz +0 -0
  94. package/build/client/_app/immutable/chunks/C7SeOWDG.js.br +0 -0
  95. package/build/client/_app/immutable/chunks/C7SeOWDG.js.gz +0 -0
  96. package/build/client/_app/immutable/chunks/DQM017d5.js +0 -1
  97. package/build/client/_app/immutable/chunks/DQM017d5.js.br +0 -0
  98. package/build/client/_app/immutable/chunks/DQM017d5.js.gz +0 -0
  99. package/build/client/_app/immutable/entry/app.CP7226A7.js.br +0 -0
  100. package/build/client/_app/immutable/entry/app.CP7226A7.js.gz +0 -0
  101. package/build/client/_app/immutable/entry/start.mGPvkOah.js +0 -1
  102. package/build/client/_app/immutable/entry/start.mGPvkOah.js.br +0 -2
  103. package/build/client/_app/immutable/entry/start.mGPvkOah.js.gz +0 -0
  104. package/build/client/_app/immutable/nodes/0.DwU44ZAj.js.br +0 -0
  105. package/build/client/_app/immutable/nodes/0.DwU44ZAj.js.gz +0 -0
  106. package/build/client/_app/immutable/nodes/1.CChG-n6d.js.br +0 -0
  107. package/build/client/_app/immutable/nodes/1.CChG-n6d.js.gz +0 -0
  108. package/build/client/_app/immutable/nodes/2.CzexDbwp.js.br +0 -0
  109. package/build/client/_app/immutable/nodes/2.CzexDbwp.js.gz +0 -0
  110. package/build/client/_app/immutable/nodes/3.DC3WghxB.js.br +0 -0
  111. package/build/client/_app/immutable/nodes/3.DC3WghxB.js.gz +0 -0
  112. package/build/client/_app/immutable/nodes/5.DRvLQ5NR.js +0 -1
  113. package/build/client/_app/immutable/nodes/5.DRvLQ5NR.js.br +0 -0
  114. package/build/client/_app/immutable/nodes/5.DRvLQ5NR.js.gz +0 -0
  115. package/build/client/_app/immutable/nodes/6.C4aXlZQd.js.br +0 -0
  116. package/build/client/_app/immutable/nodes/6.C4aXlZQd.js.gz +0 -0
  117. package/build/client/_app/immutable/nodes/7.DfniCleW.js.br +0 -0
  118. package/build/client/_app/immutable/nodes/7.DfniCleW.js.gz +0 -0
  119. package/build/client/_app/immutable/nodes/8.D4AzZWcq.js.br +0 -0
  120. package/build/client/_app/immutable/nodes/8.D4AzZWcq.js.gz +0 -0
  121. package/build/client/_app/immutable/nodes/9.gV8oJWv_.js.br +0 -0
  122. package/build/client/_app/immutable/nodes/9.gV8oJWv_.js.gz +0 -0
  123. package/build/server/chunks/_server.ts-G8OeADGj.js.map +0 -1
  124. package/build/server/chunks/library-apns-Cf-E-DhM.js +0 -107
  125. package/build/server/chunks/library-apns-Cf-E-DhM.js.map +0 -1
@@ -80,6 +80,13 @@ const completionTimers = new Map();
80
80
  const DEBUG_ENABLED = process.env.SHOOTER_DEBUG === 'true';
81
81
  const DEBUG_LOG_FILE = '/tmp/shooter-debug.log';
82
82
 
83
+ // Mask APNs device tokens (64-char hex) and similar long hex secrets in any
84
+ // string before logging. Keeps the first 4 + last 4 chars for debugging.
85
+ function redactSecrets(value) {
86
+ if (typeof value !== 'string') return value;
87
+ return value.replace(/[0-9a-f]{40,}/gi, (m) => `${m.slice(0, 4)}…${m.slice(-4)}`);
88
+ }
89
+
83
90
  // ============================================
84
91
  // SECTION 1.5: WebSocket Client Detection
85
92
  // ============================================
@@ -221,6 +228,10 @@ function createCommonEvent(source, eventType, data = {}) {
221
228
  function adaptClaudeCodeEvent(cliArg, stdinData) {
222
229
  const data = {};
223
230
 
231
+ // session_id is sent by Claude Code on every hook — capture it once so
232
+ // getSessionContext() can read the goal + last user msg + last assistant text.
233
+ data.sessionId = stdinData?.session_id || '';
234
+
224
235
  // --- PermissionRequest: Agent needs user permission to run a tool ---
225
236
  if (cliArg === 'PermissionRequest') {
226
237
  data.tool = stdinData?.tool_name || process.env.CLAUDE_TOOL_NAME || 'Unknown';
@@ -229,7 +240,6 @@ function adaptClaudeCodeEvent(cliArg, stdinData) {
229
240
  data.command = data.toolInput.command || '';
230
241
  data.filePath = data.toolInput.file_path || '';
231
242
  data.description = data.toolInput.description || '';
232
- data.sessionId = stdinData?.session_id || '';
233
243
  return createCommonEvent('claude-code', 'permission', data);
234
244
  }
235
245
 
@@ -623,7 +633,8 @@ async function handlePermission(event) {
623
633
  const d = event.data;
624
634
  debugLog(`Permission event: tool=${d.tool}, message=${d.message}`);
625
635
 
626
- const { title, body } = buildPermissionNotification(event);
636
+ const ctx = getSessionContext(d.sessionId);
637
+ const { title, subtitle, body } = buildPermissionNotification(event, ctx);
627
638
 
628
639
  // Check if WebSocket clients are connected — if so, the events channel
629
640
  // will broadcast the permission-requested event and we skip the push notification
@@ -655,11 +666,19 @@ async function handlePermission(event) {
655
666
  event.source,
656
667
  requestId,
657
668
  d,
658
- { skipPush: true }
669
+ { skipPush: true, subtitle }
659
670
  );
660
671
  } else {
661
672
  // No WebSocket clients — send push notification and poll
662
- result = await sendNotificationAndPoll(title, body, 'permission', event.source, requestId, d);
673
+ result = await sendNotificationAndPoll(
674
+ title,
675
+ body,
676
+ 'permission',
677
+ event.source,
678
+ requestId,
679
+ d,
680
+ { subtitle }
681
+ );
663
682
  }
664
683
 
665
684
  if (result && result.decision) {
@@ -684,7 +703,7 @@ async function handlePermission(event) {
684
703
  if (wsActive) {
685
704
  debugLog(`[Notifier] WebSocket clients connected, skipping push notification for permission`);
686
705
  } else {
687
- sendNotification(title, body, 'permission', event.source);
706
+ sendNotification(title, body, 'permission', event.source, subtitle);
688
707
  }
689
708
  }
690
709
 
@@ -706,8 +725,9 @@ async function handlePermissionNotification(event) {
706
725
  return;
707
726
  }
708
727
 
709
- const { title, body } = buildPermissionNotification(event);
710
- sendNotification(title, body, 'permission', event.source);
728
+ const ctx = getSessionContext(event.data.sessionId);
729
+ const { title, subtitle, body } = buildPermissionNotification(event, ctx);
730
+ sendNotification(title, body, 'permission', event.source, subtitle);
711
731
  }
712
732
 
713
733
  /**
@@ -720,9 +740,10 @@ function handleQuestion(event) {
720
740
  const d = event.data;
721
741
  debugLog(`Question event: message=${d.message}`);
722
742
 
723
- const { title, body } = buildQuestionNotification(event);
743
+ const ctx = getSessionContext(d.sessionId);
744
+ const { title, subtitle, body } = buildQuestionNotification(event, ctx);
724
745
 
725
- sendNotification(title, body, 'question', event.source);
746
+ sendNotification(title, body, 'question', event.source, subtitle);
726
747
  }
727
748
 
728
749
  /**
@@ -734,10 +755,34 @@ function handleIdleInput(event) {
734
755
  const d = event.data;
735
756
  debugLog(`Idle input event: message=${d.message}`);
736
757
 
737
- const title = `Waiting for Input`;
738
- const body = d.message || `Agent is waiting for your input in ${event.projectName}`;
758
+ const ctx = getSessionContext(d.sessionId);
759
+ const title = `${event.projectName} · Waiting for input`;
760
+ const subtitle = ctx.goal
761
+ ? `Goal: ${summarize(ctx.goal, 70)}`
762
+ : summarize(d.message) || 'Agent is idle';
763
+
764
+ // Body priority: when Claude's hook message is boilerplate ("waiting for
765
+ // your input"), the agent's most recent text from the JSONL session carries
766
+ // the real "current state". Fall back to Claude's message when we can't
767
+ // read the session, or when the message has substance.
768
+ const lines = [];
769
+ const hookIsBoilerplate = isBoilerplate(d.message);
770
+ if (hookIsBoilerplate && ctx.lastAssistantText) {
771
+ lines.push(ctx.lastAssistantText);
772
+ } else if (d.message) {
773
+ lines.push(d.message);
774
+ } else if (ctx.lastAssistantText) {
775
+ lines.push(ctx.lastAssistantText);
776
+ } else {
777
+ lines.push('Agent is waiting.');
778
+ }
779
+ if (ctx.lastUserMessage && ctx.lastUserMessage !== ctx.goal) {
780
+ lines.push(`Asked: ${summarize(ctx.lastUserMessage, 120)}`);
781
+ }
782
+ let body = lines.join('\n');
783
+ if (body.length > 600) body = body.substring(0, 600) + '…';
739
784
 
740
- sendNotification(title, body, 'idle_input', event.source);
785
+ sendNotification(title, body, 'idle_input', event.source, subtitle);
741
786
  }
742
787
 
743
788
  /**
@@ -749,20 +794,35 @@ function handleIntervention(event) {
749
794
  const d = event.data;
750
795
  debugLog(`Intervention event: message=${d.message}`);
751
796
 
752
- const title = d.title || `Needs Attention`;
753
- const body = d.message || `Needs your attention in ${event.projectName}`;
797
+ const ctx = getSessionContext(d.sessionId);
798
+ const title = `${event.projectName} · Needs attention`;
799
+ const subtitle = ctx.goal
800
+ ? `Goal: ${summarize(ctx.goal, 70)}`
801
+ : summarize(d.message) || summarize(d.title) || 'Intervention required';
802
+
803
+ const lines = [];
804
+ if (isBoilerplate(d.message) && ctx.lastAssistantText) {
805
+ lines.push(ctx.lastAssistantText);
806
+ } else if (d.message) {
807
+ lines.push(d.message);
808
+ } else if (ctx.lastAssistantText) {
809
+ lines.push(ctx.lastAssistantText);
810
+ } else {
811
+ lines.push('Agent needs your attention.');
812
+ }
813
+ if (ctx.lastUserMessage && ctx.lastUserMessage !== ctx.goal) {
814
+ lines.push(`Asked: ${summarize(ctx.lastUserMessage, 120)}`);
815
+ }
816
+ let body = lines.join('\n');
817
+ if (body.length > 600) body = body.substring(0, 600) + '…';
754
818
 
755
- sendNotification(title, body, 'intervention', event.source);
819
+ sendNotification(title, body, 'intervention', event.source, subtitle);
756
820
  }
757
821
 
758
822
  function handleError(event) {
759
- debugLog(`Error detected: ${event.data.message}`);
760
- sendNotification(
761
- `Error in ${event.projectName}`,
762
- event.data.message || 'An error occurred',
763
- 'error',
764
- event.source
765
- );
823
+ // Errors are not actionable from the phone — the user can't fix a build error
824
+ // remotely. Track for telemetry only; do not send a push notification.
825
+ debugLog(`Error detected (not notifying): ${event.data.message}`);
766
826
  }
767
827
 
768
828
  function handleCheckCompletion(event) {
@@ -804,23 +864,13 @@ function handleUserPrompt(event) {
804
864
  }
805
865
 
806
866
  function handleTeammateIdle(event) {
807
- debugLog(`Teammate idle: ${event.data.teammate}`);
808
- sendNotification(
809
- `Teammate Idle`,
810
- `${event.data.teammate} is idle in ${event.projectName}`,
811
- 'teammate_idle',
812
- event.source
813
- );
867
+ // Informational only — no user input required. Skip notification.
868
+ debugLog(`Teammate idle (not notifying): ${event.data.teammate}`);
814
869
  }
815
870
 
816
871
  function handleTaskCompleted(event) {
817
- debugLog(`Task completed: ${event.data.message}`);
818
- sendNotification(
819
- `Task Completed`,
820
- event.data.message || `A task was completed in ${event.projectName}`,
821
- 'task_completed',
822
- event.source
823
- );
872
+ // Informational only — task completion does not require a decision. Skip.
873
+ debugLog(`Task completed (not notifying): ${event.data.message}`);
824
874
  }
825
875
 
826
876
  // ============================================
@@ -828,18 +878,274 @@ function handleTaskCompleted(event) {
828
878
  // ============================================
829
879
 
830
880
  /**
831
- * Build permission notification content.
832
- * Same structure regardless of source (Claude Code or OpenCode).
881
+ * Map a tool name to a short verb describing what it's about to do.
882
+ * Used in the subtitle line, e.g. "Bash run command".
883
+ * Returns 'use tool' as the catch-all so MCP / unknown tools still render cleanly.
884
+ */
885
+ function toolVerb(toolName) {
886
+ switch (toolName) {
887
+ case 'Bash':
888
+ return 'run command';
889
+ case 'BashOutput':
890
+ return 'read shell output';
891
+ case 'Edit':
892
+ return 'modify file';
893
+ case 'Glob':
894
+ return 'search files';
895
+ case 'Grep':
896
+ return 'search content';
897
+ case 'KillShell':
898
+ return 'kill shell';
899
+ case 'NotebookEdit':
900
+ return 'edit notebook';
901
+ case 'Read':
902
+ return 'read file';
903
+ case 'Skill':
904
+ return 'invoke skill';
905
+ case 'Task':
906
+ return 'spawn agent';
907
+ case 'TodoWrite':
908
+ return 'manage tasks';
909
+ case 'WebFetch':
910
+ return 'fetch URL';
911
+ case 'WebSearch':
912
+ return 'search web';
913
+ case 'Write':
914
+ return 'create file';
915
+ default:
916
+ return 'use tool';
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Build the metadata footer appended to every notification body.
922
+ * Replaces the old "[Claude] [LOCAL]" title prefix with a low-weight suffix
923
+ * line, e.g. "— claude code" or "— opencode". The local/remote breadcrumb is
924
+ * intentionally omitted — `data.environment` carries it for debug consumers.
925
+ */
926
+ function buildFooter(source) {
927
+ const runtime = source === 'opencode' ? 'opencode' : 'claude code';
928
+ return `— ${runtime}`;
929
+ }
930
+
931
+ /** Append the runtime footer to a body, separated by a newline. */
932
+ function withFooter(body, source) {
933
+ const footer = buildFooter(source);
934
+ if (!body) return footer;
935
+ return `${body}\n${footer}`;
936
+ }
937
+
938
+ /**
939
+ * Read head and tail chunks from a Claude Code session JSONL file.
833
940
  *
834
- * When we have tool details:
835
- * Title: "Permission: Bash"
836
- * Body: "npm test" or "Allow: rm -rf /tmp/build"
941
+ * Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl
942
+ * where the encoded cwd replaces every '/' with '-'. Returns parsed lines from
943
+ * the head (for the goal — first user prompt) and tail (for the last user msg
944
+ * and last assistant text). For small sessions, both arrays are the same.
837
945
  *
838
- * When we only have a message:
839
- * Title: "Permission Needed"
840
- * Body: "Claude needs your permission to use Bash"
946
+ * Returns { firstLines, lastLines } as arrays of trimmed line strings.
947
+ * Returns { firstLines: [], lastLines: [] } on any failure.
841
948
  */
842
- function buildPermissionNotification(event) {
949
+ function readSessionLines(sessionId, cwd) {
950
+ if (!sessionId) return { firstLines: [], lastLines: [] };
951
+ try {
952
+ const projectsDir = path.join(require('os').homedir(), '.claude', 'projects');
953
+ const encodedCwd = (cwd || process.cwd()).replace(/\//g, '-');
954
+ const sessionFile = path.join(projectsDir, encodedCwd, `${sessionId}.jsonl`);
955
+
956
+ if (!fs.existsSync(sessionFile)) return { firstLines: [], lastLines: [] };
957
+
958
+ const stat = fs.statSync(sessionFile);
959
+ const headSize = 262144; // 256 KB — enough to find the first user message
960
+ const tailSize = 524288; // 512 KB — enough to find the most recent ones
961
+ const fd = fs.openSync(sessionFile, 'r');
962
+
963
+ let firstLines, lastLines;
964
+ if (stat.size <= headSize + tailSize) {
965
+ // Small/medium session — read whole file once
966
+ const buf = Buffer.alloc(stat.size);
967
+ fs.readSync(fd, buf, 0, stat.size, 0);
968
+ fs.closeSync(fd);
969
+ const all = buf.toString('utf-8').split('\n').filter((l) => l.trim());
970
+ firstLines = all;
971
+ lastLines = all;
972
+ } else {
973
+ // Huge session — read both ends. Drop the boundary lines (likely partial).
974
+ const headBuf = Buffer.alloc(headSize);
975
+ fs.readSync(fd, headBuf, 0, headSize, 0);
976
+ const tailBuf = Buffer.alloc(tailSize);
977
+ fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
978
+ fs.closeSync(fd);
979
+
980
+ firstLines = headBuf.toString('utf-8').split('\n').filter((l) => l.trim());
981
+ if (firstLines.length > 0) firstLines.pop(); // drop possibly-partial last line
982
+ lastLines = tailBuf.toString('utf-8').split('\n').filter((l) => l.trim());
983
+ if (lastLines.length > 0) lastLines.shift(); // drop possibly-partial first line
984
+ }
985
+
986
+ return { firstLines, lastLines };
987
+ } catch {
988
+ return { firstLines: [], lastLines: [] };
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Extract the user prompt text from a parsed JSONL entry, or '' if it isn't a
994
+ * real user message (tool result, system-injected wrapper, empty, etc.).
995
+ */
996
+ function extractUserPromptText(entry) {
997
+ if (!entry || entry.type !== 'user' || !entry.message) return '';
998
+ const content = entry.message.content;
999
+ let text = '';
1000
+ if (typeof content === 'string') {
1001
+ text = content;
1002
+ } else if (Array.isArray(content)) {
1003
+ for (const block of content) {
1004
+ if (!block || typeof block !== 'object') continue;
1005
+ if (block.type === 'text' && typeof block.text === 'string') {
1006
+ text = block.text;
1007
+ break;
1008
+ }
1009
+ // tool_result and other non-text blocks are not user prompts
1010
+ }
1011
+ }
1012
+ if (!text) return '';
1013
+ const trimmed = text.replace(/\s+/g, ' ').trim();
1014
+ if (!trimmed) return '';
1015
+
1016
+ // Wrappers Claude Code injects that aren't real user prompts.
1017
+ if (trimmed.startsWith('<system-reminder>')) return '';
1018
+ if (trimmed.startsWith('<command-message>')) return '';
1019
+ if (trimmed.startsWith('<command-name>')) return '';
1020
+ if (trimmed.startsWith('<bash-stdout>')) return '';
1021
+ if (trimmed.startsWith('<bash-stderr>')) return '';
1022
+ if (trimmed.startsWith('Caveat:')) return '';
1023
+ if (trimmed.startsWith('Base directory for this skill:')) return '';
1024
+
1025
+ return trimmed;
1026
+ }
1027
+
1028
+ /**
1029
+ * Extract user-facing assistant text from a parsed JSONL entry. Skips thinking
1030
+ * blocks and tool_use blocks; returns the first text block's content.
1031
+ */
1032
+ function extractAssistantText(entry) {
1033
+ if (!entry || entry.type !== 'assistant' || !entry.message) return '';
1034
+ const content = entry.message.content;
1035
+ if (!Array.isArray(content)) return '';
1036
+ for (const block of content) {
1037
+ if (!block || typeof block !== 'object') continue;
1038
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
1039
+ return block.text.replace(/\s+/g, ' ').trim();
1040
+ }
1041
+ }
1042
+ return '';
1043
+ }
1044
+
1045
+ /**
1046
+ * Walk session JSONL and surface the three pieces of context we need:
1047
+ * - goal: first real user prompt (the original ask)
1048
+ * - lastUserMessage: most recent real user prompt (the most recent ask)
1049
+ * - lastAssistantText: most recent agent text output (current state)
1050
+ *
1051
+ * Cheap enough to call per notification — bounded by readSessionLines's caps.
1052
+ */
1053
+ function getSessionContext(sessionId, cwd) {
1054
+ const empty = { goal: '', lastUserMessage: '', lastAssistantText: '' };
1055
+ const { firstLines, lastLines } = readSessionLines(sessionId, cwd);
1056
+ if (firstLines.length === 0 && lastLines.length === 0) return empty;
1057
+
1058
+ // Goal: first user prompt in the head.
1059
+ let goal = '';
1060
+ for (const line of firstLines) {
1061
+ let entry;
1062
+ try {
1063
+ entry = JSON.parse(line);
1064
+ } catch {
1065
+ continue;
1066
+ }
1067
+ const text = extractUserPromptText(entry);
1068
+ if (text) {
1069
+ goal = text;
1070
+ break;
1071
+ }
1072
+ }
1073
+
1074
+ // Last user message and last assistant text: walk tail backwards.
1075
+ let lastUserMessage = '';
1076
+ let lastAssistantText = '';
1077
+ for (let i = lastLines.length - 1; i >= 0; i--) {
1078
+ if (lastUserMessage && lastAssistantText) break;
1079
+ let entry;
1080
+ try {
1081
+ entry = JSON.parse(lastLines[i]);
1082
+ } catch {
1083
+ continue;
1084
+ }
1085
+ if (!lastUserMessage) {
1086
+ const t = extractUserPromptText(entry);
1087
+ if (t) lastUserMessage = t;
1088
+ }
1089
+ if (!lastAssistantText) {
1090
+ const t = extractAssistantText(entry);
1091
+ if (t) lastAssistantText = t;
1092
+ }
1093
+ }
1094
+
1095
+ return { goal, lastUserMessage, lastAssistantText };
1096
+ }
1097
+
1098
+ /**
1099
+ * Detect Claude Code's boilerplate hook messages — short, generic strings like
1100
+ * "Claude is waiting for your input." that don't tell the user anything new
1101
+ * beyond what the title already says. When a hook message looks like this we
1102
+ * fall back to JSONL-derived assistant text, which carries real context.
1103
+ */
1104
+ function isBoilerplate(text) {
1105
+ if (!text) return true;
1106
+ const t = String(text).trim().toLowerCase();
1107
+ if (t.length < 8) return true;
1108
+ if (t.length > 80) return false;
1109
+ return (
1110
+ /\bwaiting\b/.test(t) ||
1111
+ /\bawaiting\b/.test(t) ||
1112
+ /\bidle\b/.test(t) ||
1113
+ /^(claude is|agent is|ready)\b/.test(t) ||
1114
+ /\b(needs your input|input needed|attention required)\b/.test(t)
1115
+ );
1116
+ }
1117
+
1118
+ /**
1119
+ * Compress a message into a subtitle-sized summary.
1120
+ * Prefers the first sentence; falls back to a hard char limit. Whitespace and
1121
+ * newlines are normalised so multi-line agent prose collapses into one line.
1122
+ */
1123
+ function summarize(text, maxLen = 80) {
1124
+ if (!text) return '';
1125
+ const trimmed = String(text).replace(/\s+/g, ' ').trim();
1126
+ if (!trimmed) return '';
1127
+
1128
+ // First sentence: ends in . ? or ! followed by whitespace or end-of-string.
1129
+ // Lookahead avoids slicing at file extensions ("auth.ts") or abbreviations.
1130
+ const sentenceMatch = trimmed.match(/^[^.!?]+[.!?](?=\s|$)/);
1131
+ if (sentenceMatch && sentenceMatch[0].length <= maxLen) {
1132
+ return sentenceMatch[0].trim();
1133
+ }
1134
+
1135
+ if (trimmed.length <= maxLen) return trimmed;
1136
+ return trimmed.substring(0, maxLen).trim() + '…';
1137
+ }
1138
+
1139
+ /**
1140
+ * Build permission notification content.
1141
+ * Returns a consistent { title, subtitle, body } shape regardless of tool or source.
1142
+ *
1143
+ * title: "<project> · Permission needed"
1144
+ * subtitle: "<Tool> — <verb>" (e.g. "Bash — run command")
1145
+ * body: <description if present>
1146
+ * <command / file path / argument summary>
1147
+ */
1148
+ function buildPermissionNotification(event, ctx = {}) {
843
1149
  const d = event.data;
844
1150
  const toolName = d.tool || '';
845
1151
  const command = d.command || '';
@@ -847,101 +1153,141 @@ function buildPermissionNotification(event) {
847
1153
  const description = d.description || '';
848
1154
  const message = d.message || '';
849
1155
 
850
- // Case 1: We know the tool name and have details
1156
+ const goal = ctx.goal || '';
1157
+ const lastUserMessage = ctx.lastUserMessage || '';
1158
+
1159
+ const title = `${event.projectName} · Permission needed`;
1160
+
1161
+ // Layered shape:
1162
+ // title = project + kind
1163
+ // subtitle = original goal (highest-level context)
1164
+ // body = "<Tool>: <description>" (what the agent wants to do now)
1165
+ // "Asked: <last user msg>" (only if distinct from goal)
1166
+ // <command/file> (the specific data)
1167
+ // footer (added by sendNotification)
851
1168
  if (toolName && toolName !== 'Unknown' && toolName !== '') {
852
- const title = `Permission: ${toolName}`;
853
- let body = '';
1169
+ const subtitle = goal
1170
+ ? `Goal: ${summarize(goal, 70)}`
1171
+ : description
1172
+ ? `${toolName}: ${summarize(description, 70)}`
1173
+ : `${toolName} — ${toolVerb(toolName)}`;
854
1174
 
1175
+ let detail = '';
855
1176
  if (toolName === 'Bash' && command) {
856
- // For Bash, show the command
857
- body = command.length > 200 ? command.substring(0, 200) + '...' : command;
858
- } else if ((toolName === 'Edit' || toolName === 'Write' || toolName === 'Read') && filePath) {
859
- // For file operations, show the file path
860
- body = filePath;
861
- } else if (description) {
862
- body = description;
1177
+ detail = command;
1178
+ } else if (
1179
+ (toolName === 'Edit' ||
1180
+ toolName === 'Write' ||
1181
+ toolName === 'Read' ||
1182
+ toolName === 'NotebookEdit') &&
1183
+ filePath
1184
+ ) {
1185
+ detail = filePath;
863
1186
  } else if (command) {
864
- body = command;
1187
+ detail = command;
865
1188
  } else if (filePath) {
866
- body = filePath;
867
- } else {
868
- body = `Approve ${toolName} in ${event.projectName}`;
1189
+ detail = filePath;
1190
+ }
1191
+ if (detail.length > 500) detail = detail.substring(0, 500) + '…';
1192
+
1193
+ const lines = [];
1194
+ // What the agent wants to do (description) — only push when goal occupies
1195
+ // the subtitle, otherwise it's in the subtitle already.
1196
+ if (goal && description) {
1197
+ lines.push(`${toolName}: ${description}`);
1198
+ }
1199
+ // What the user most recently asked — only when it's a distinct follow-up,
1200
+ // not the original goal restated.
1201
+ if (lastUserMessage && lastUserMessage !== goal) {
1202
+ lines.push(`Asked: ${summarize(lastUserMessage, 120)}`);
1203
+ }
1204
+ if (detail) {
1205
+ lines.push(detail);
1206
+ }
1207
+ if (lines.length === 0) {
1208
+ lines.push(message || `Approve ${toolName}`);
869
1209
  }
870
1210
 
871
- return { title, body };
1211
+ return { title, subtitle, body: lines.join('\n') };
872
1212
  }
873
1213
 
874
- // Case 2: We only have a message (e.g., from Notification event)
1214
+ // No tool name (Notification permission_prompt path).
1215
+ const subtitle = goal
1216
+ ? `Goal: ${summarize(goal, 70)}`
1217
+ : summarize(message) || 'Permission';
1218
+
875
1219
  if (message) {
876
- return {
877
- title: `Permission Needed`,
878
- body: message,
879
- };
1220
+ const lines = [message];
1221
+ if (lastUserMessage && lastUserMessage !== goal && lastUserMessage !== message) {
1222
+ lines.push(`Asked: ${summarize(lastUserMessage, 120)}`);
1223
+ }
1224
+ return { title, subtitle, body: lines.join('\n') };
880
1225
  }
881
1226
 
882
- // Case 3: Minimal fallback
883
- return {
884
- title: `Permission Needed`,
885
- body: `Agent needs permission in ${event.projectName}`,
886
- };
1227
+ return { title, subtitle, body: 'Agent needs permission' };
887
1228
  }
888
1229
 
889
1230
  /**
890
1231
  * Build question/elicitation notification content.
891
- * Same structure regardless of source.
892
- *
893
- * Handles two formats:
894
- * 1. Claude Code: { message: "question text", title: "..." }
895
- * 2. OpenCode: { questions: [{ header, options: [{ label, description }] }] }
1232
+ * Returns { title, subtitle, body }.
896
1233
  *
897
- * Output is always:
898
- * Title: "Question: <header>" or "Question"
899
- * Body: "<question text> | Options: A / B / C"
1234
+ * title: "<project> · Question"
1235
+ * subtitle: <question header (truncated)> (or 'Awaiting answer' fallback)
1236
+ * body: <question text>
1237
+ * [Options: A / B / C]
900
1238
  */
901
- function buildQuestionNotification(event) {
1239
+ function buildQuestionNotification(event, ctx = {}) {
902
1240
  const d = event.data;
903
1241
  const message = d.message || '';
904
- const title = d.title || '';
1242
+ const title = `${event.projectName} · Question`;
905
1243
  const questions = d.questions || [];
906
1244
 
1245
+ const goal = ctx.goal || '';
1246
+ const lastUserMessage = ctx.lastUserMessage || '';
1247
+
907
1248
  // Case 1: OpenCode question.asked with structured questions array
908
1249
  if (questions.length > 0) {
909
- const q = questions[0]; // Use first question
1250
+ const q = questions[0];
910
1251
  const header = q.header || q.question || '';
911
1252
  const options = (q.options || []).map((o) => o.label).filter(Boolean);
912
1253
 
913
- const notifTitle = header ? `Question: ${header}` : 'Question';
914
- let body = q.question || header || '';
1254
+ const subtitle = goal
1255
+ ? `Goal: ${summarize(goal, 70)}`
1256
+ : summarize(header || q.question, 80) || 'Awaiting answer';
915
1257
 
916
- if (options.length > 0) {
917
- body = body ? `${body} | Options: ${options.join(' / ')}` : `Options: ${options.join(' / ')}`;
1258
+ const lines = [];
1259
+ if (q.question) lines.push(q.question);
1260
+ else if (header) lines.push(header);
1261
+ if (options.length > 0) lines.push(`Options: ${options.join(' / ')}`);
1262
+ if (lastUserMessage && lastUserMessage !== goal) {
1263
+ lines.push(`Asked: ${summarize(lastUserMessage, 120)}`);
918
1264
  }
1265
+ if (lines.length === 0) lines.push('Agent is asking a question');
919
1266
 
920
- if (!body) {
921
- body = `Agent is asking a question in ${event.projectName}`;
922
- }
1267
+ let body = lines.join('\n');
1268
+ if (body.length > 500) body = body.substring(0, 500) + '…';
923
1269
 
924
- return {
925
- title: notifTitle,
926
- body: body.length > 300 ? body.substring(0, 300) + '...' : body,
927
- };
1270
+ return { title, subtitle, body };
928
1271
  }
929
1272
 
930
- // Case 2: Claude Code notification with message text
931
- const notifTitle = title && title !== 'Permission needed' ? title : 'Question';
1273
+ // Case 2: Claude Code Notification (elicitation_dialog). Goal is the original
1274
+ // user prompt; the agent's question itself is the body.
1275
+ const stdinTitle = d.title || '';
1276
+ const subtitle = goal
1277
+ ? `Goal: ${summarize(goal, 70)}`
1278
+ : summarize(message, 80) ||
1279
+ (stdinTitle && stdinTitle !== 'Permission needed' ? summarize(stdinTitle, 80) : '') ||
1280
+ 'Awaiting answer';
932
1281
 
933
1282
  if (message) {
934
- return {
935
- title: notifTitle,
936
- body: message.length > 300 ? message.substring(0, 300) + '...' : message,
937
- };
1283
+ const lines = [message.length > 500 ? message.substring(0, 500) + '…' : message];
1284
+ if (lastUserMessage && lastUserMessage !== goal && lastUserMessage !== message) {
1285
+ lines.push(`Asked: ${summarize(lastUserMessage, 120)}`);
1286
+ }
1287
+ return { title, subtitle, body: lines.join('\n') };
938
1288
  }
939
1289
 
940
- // Case 3: Minimal fallback
941
- return {
942
- title: 'Question',
943
- body: `Agent is asking a question in ${event.projectName}`,
944
- };
1290
+ return { title, subtitle, body: 'Agent is asking a question' };
945
1291
  }
946
1292
 
947
1293
  // ============================================
@@ -996,7 +1342,13 @@ function checkCompletion(projectName, source) {
996
1342
 
997
1343
  const message = createCompletionMessage(state, projectName);
998
1344
 
999
- sendNotification(`${projectName} Complete`, message, 'completion', source);
1345
+ sendNotification(
1346
+ `${projectName} · Session complete`,
1347
+ message,
1348
+ 'completion',
1349
+ source,
1350
+ 'Agent finished'
1351
+ );
1000
1352
 
1001
1353
  state.pendingCompletion = false;
1002
1354
  saveSessionState(state);
@@ -1005,26 +1357,24 @@ function checkCompletion(projectName, source) {
1005
1357
  }
1006
1358
  }
1007
1359
 
1008
- function createCompletionMessage(state, projectName) {
1360
+ function createCompletionMessage(state, _projectName) {
1009
1361
  const timestamp = new Date().toLocaleTimeString();
1010
- let message = `Session completed in ${projectName} at ${timestamp}`;
1362
+ const lines = [`Finished at ${timestamp}.`];
1011
1363
 
1012
1364
  const totalTools = state.totalToolUses || 0;
1013
1365
  if (totalTools > 0) {
1014
- message += ` | ${totalTools} tools used`;
1366
+ lines.push(`${totalTools} tools used.`);
1015
1367
 
1016
1368
  if (state.recentTools && state.recentTools.length > 0) {
1017
- const toolSummary = state.recentTools.slice(0, 3).join(', ');
1018
- message += ` | Recent: ${toolSummary}`;
1369
+ lines.push(`Recent: ${state.recentTools.slice(0, 3).join(', ')}`);
1019
1370
  }
1020
1371
 
1021
1372
  if (state.recentFiles && state.recentFiles.length > 0) {
1022
- const fileSummary = state.recentFiles.slice(0, 3).join(', ');
1023
- message += ` | Files: ${fileSummary}`;
1373
+ lines.push(`Files: ${state.recentFiles.slice(0, 3).join(', ')}`);
1024
1374
  }
1025
1375
  }
1026
1376
 
1027
- return message;
1377
+ return lines.join('\n');
1028
1378
  }
1029
1379
 
1030
1380
  // ============================================
@@ -1044,14 +1394,14 @@ function sendNotificationAndPoll(
1044
1394
  source,
1045
1395
  requestId,
1046
1396
  eventData,
1047
- { skipPush = false } = {}
1397
+ { skipPush = false, subtitle = '' } = {}
1048
1398
  ) {
1049
1399
  return new Promise((resolve) => {
1050
1400
  const timestamp = new Date().toISOString();
1051
1401
 
1052
- const runtimePrefix = source === 'opencode' ? '[OpenCode]' : '[Claude]';
1053
- const envPrefix = USE_LOCAL ? '[LOCAL]' : '';
1054
- const finalTitle = `${runtimePrefix}${envPrefix ? ' ' + envPrefix : ''} ${title}`;
1402
+ // Title flows through unchanged; runtime/env metadata moves to a body suffix
1403
+ // line so it doesn't eat title space on the lock screen.
1404
+ const finalBody = withFooter(body, source);
1055
1405
 
1056
1406
  // When skipPush is true (WebSocket clients connected), skip the actual
1057
1407
  // POST to /api/notify but still register the pending request and poll.
@@ -1063,8 +1413,9 @@ function sendNotificationAndPoll(
1063
1413
  // We still need to POST with waitForResponse so the server creates
1064
1414
  // the pending-request entry, but we mark it as ws-only.
1065
1415
  const registerPayload = JSON.stringify({
1066
- title: finalTitle,
1067
- message: body,
1416
+ title,
1417
+ ...(subtitle ? { subtitle } : {}),
1418
+ message: finalBody,
1068
1419
  waitForResponse: true,
1069
1420
  skipPush: true,
1070
1421
  ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
@@ -1128,11 +1479,12 @@ function sendNotificationAndPoll(
1128
1479
  return;
1129
1480
  }
1130
1481
 
1131
- debugLog(`Sending bidirectional notification: "${finalTitle}" (requestId: ${requestId})`);
1482
+ debugLog(`Sending bidirectional notification: "${title}" (requestId: ${requestId})`);
1132
1483
 
1133
1484
  const payload = JSON.stringify({
1134
- title: finalTitle,
1135
- message: body,
1485
+ title,
1486
+ ...(subtitle ? { subtitle } : {}),
1487
+ message: finalBody,
1136
1488
  waitForResponse: true,
1137
1489
  ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
1138
1490
  data: {
@@ -1168,8 +1520,9 @@ function sendNotificationAndPoll(
1168
1520
  res.on('end', () => {
1169
1521
  if (IS_CLAUDE_CODE) {
1170
1522
  console.error(`\n=== BIDIRECTIONAL NOTIFICATION SENT [${requestId}] ===`);
1171
- console.error(`Title: ${finalTitle}`);
1172
- console.error(`Message: ${body}`);
1523
+ console.error(`Title: ${title}`);
1524
+ if (subtitle) console.error(`Subtitle: ${subtitle}`);
1525
+ console.error(`Message: ${finalBody}`);
1173
1526
  console.error(`Status: ${res.statusCode}`);
1174
1527
  console.error(`=== NOW POLLING FOR RESPONSE ===\n`);
1175
1528
  }
@@ -1285,22 +1638,22 @@ function startPolling(requestId, resolve) {
1285
1638
  }, POLL_INTERVAL);
1286
1639
  }
1287
1640
 
1288
- function sendNotification(title, body, category = 'completion', source = RUNTIME) {
1641
+ function sendNotification(title, body, category = 'completion', source = RUNTIME, subtitle = '') {
1289
1642
  const requestId = Math.random().toString(36).substring(2, 15);
1290
1643
  const timestamp = new Date().toISOString();
1291
1644
 
1292
- // Prefix: [Claude] or [OpenCode], optionally [LOCAL]
1293
- const runtimePrefix = source === 'opencode' ? '[OpenCode]' : '[Claude]';
1294
- const envPrefix = USE_LOCAL ? '[LOCAL]' : '';
1295
- const finalTitle = `${runtimePrefix}${envPrefix ? ' ' + envPrefix : ''} ${title}`;
1645
+ // Title flows through unchanged; runtime/env metadata is appended as a low-weight
1646
+ // body suffix (see buildFooter / withFooter in Section 8).
1647
+ const finalBody = withFooter(body, source);
1296
1648
 
1297
- debugLog(`Sending notification: "${finalTitle}"`);
1298
- debugLog(` Message: "${body.substring(0, 100)}..."`);
1649
+ debugLog(`Sending notification: "${title}"`);
1650
+ debugLog(` Message: "${finalBody.substring(0, 100)}..."`);
1299
1651
  debugLog(` Category: ${category}, RequestID: ${requestId}`);
1300
1652
 
1301
1653
  const payload = JSON.stringify({
1302
- title: finalTitle,
1303
- message: body,
1654
+ title,
1655
+ ...(subtitle ? { subtitle } : {}),
1656
+ message: finalBody,
1304
1657
  ...(DEVICE_TOKEN && { deviceToken: DEVICE_TOKEN }),
1305
1658
  data: {
1306
1659
  category,
@@ -1333,16 +1686,17 @@ function sendNotification(title, body, category = 'completion', source = RUNTIME
1333
1686
  console.error(`\n=== NOTIFICATION SENT [${requestId}] @ ${timestamp} ===`);
1334
1687
  console.error(`Project: ${getProjectName()}`);
1335
1688
  console.error(`Category: ${category}`);
1336
- console.error(`Title: ${finalTitle}`);
1337
- console.error(`Message: ${body}`);
1689
+ console.error(`Title: ${title}`);
1690
+ if (subtitle) console.error(`Subtitle: ${subtitle}`);
1691
+ console.error(`Message: ${finalBody}`);
1338
1692
  console.error(`API URL: ${API_URL} (${USE_LOCAL ? 'LOCAL' : 'REMOTE'})`);
1339
1693
  console.error(`Status Code: ${res.statusCode}`);
1340
- console.error(`Response: ${responseData}`);
1694
+ console.error(`Response: ${redactSecrets(responseData)}`);
1341
1695
  console.error(`=== END NOTIFICATION ===\n`);
1342
1696
  }
1343
1697
 
1344
1698
  if (res.statusCode !== 200) {
1345
- debugLog(`HTTP ERROR: ${res.statusCode} ${responseData}`);
1699
+ debugLog(`HTTP ERROR: ${res.statusCode} ${redactSecrets(responseData)}`);
1346
1700
  } else {
1347
1701
  debugLog(`Notification sent successfully`);
1348
1702
  }