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