clay-server 2.27.0-beta.16 → 2.27.0-beta.18

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 (39) hide show
  1. package/lib/project-connection.js +2 -0
  2. package/lib/project-http.js +4 -2
  3. package/lib/project-loop.js +110 -48
  4. package/lib/project-notifications.js +210 -0
  5. package/lib/project-sessions.js +5 -2
  6. package/lib/project-user-message.js +2 -1
  7. package/lib/project.js +21 -1
  8. package/lib/public/app.js +300 -611
  9. package/lib/public/css/command-palette.css +14 -0
  10. package/lib/public/css/loop.css +301 -0
  11. package/lib/public/css/notifications-center.css +190 -0
  12. package/lib/public/css/rewind.css +6 -0
  13. package/lib/public/index.html +89 -35
  14. package/lib/public/modules/app-debate-ui.js +38 -48
  15. package/lib/public/modules/app-loop-ui.js +204 -480
  16. package/lib/public/modules/app-loop-wizard.js +439 -0
  17. package/lib/public/modules/app-messages.js +595 -517
  18. package/lib/public/modules/app-misc.js +19 -21
  19. package/lib/public/modules/app-notifications.js +372 -0
  20. package/lib/public/modules/app-panels.js +76 -95
  21. package/lib/public/modules/app-projects.js +14 -6
  22. package/lib/public/modules/app-rate-limit.js +31 -28
  23. package/lib/public/modules/app-rendering.js +53 -53
  24. package/lib/public/modules/app-skills-install.js +8 -14
  25. package/lib/public/modules/command-palette.js +27 -4
  26. package/lib/public/modules/input.js +1 -0
  27. package/lib/public/modules/session-search.js +13 -1
  28. package/lib/public/modules/sidebar-mates.js +12 -1
  29. package/lib/public/modules/sidebar-mobile.js +1 -1
  30. package/lib/public/modules/sidebar-sessions.js +3 -3
  31. package/lib/public/modules/store.js +27 -0
  32. package/lib/public/modules/ws-ref.js +7 -0
  33. package/lib/public/style.css +1 -0
  34. package/lib/sdk-bridge.js +18 -18
  35. package/lib/sdk-message-processor.js +18 -4
  36. package/lib/server-dm.js +15 -2
  37. package/lib/server.js +10 -0
  38. package/lib/sessions.js +11 -4
  39. package/package.json +1 -1
@@ -34,6 +34,7 @@ function attachConnection(ctx) {
34
34
  var sendTo = ctx.sendTo;
35
35
  var opts = ctx.opts;
36
36
  var _loop = ctx._loop;
37
+ var _notifications = ctx._notifications;
37
38
  var hydrateImageRefs = ctx.hydrateImageRefs;
38
39
  var broadcastClientCount = ctx.broadcastClientCount;
39
40
  var broadcastPresence = ctx.broadcastPresence;
@@ -97,6 +98,7 @@ function attachConnection(ctx) {
97
98
  sendTo(ws, { type: "notes_list", notes: nm.list() });
98
99
  sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
99
100
  _loop.sendConnectionState(ws);
101
+ if (_notifications) _notifications.sendConnectionState(ws, sendTo);
100
102
 
101
103
  // Session list (filtered for access control)
102
104
  var allSessions = [].concat(Array.from(sm.sessions.values())).filter(function (s) { return !s.hidden; });
@@ -629,11 +629,13 @@ function attachHTTP(ctx) {
629
629
  var execSync = require("child_process").execSync;
630
630
  try {
631
631
  var out = execSync("git status --porcelain", { cwd: cwd, encoding: "utf8", timeout: 5000 });
632
- var dirty = out.trim().split("\n").some(function (line) {
632
+ var lines = out.trim().split("\n").filter(function (line) {
633
633
  return line.trim().length > 0 && !line.startsWith("??");
634
634
  });
635
+ var dirty = lines.length > 0;
636
+ var files = lines.map(function (line) { return line.trim(); });
635
637
  res.writeHead(200, { "Content-Type": "application/json" });
636
- res.end(JSON.stringify({ dirty: dirty }));
638
+ res.end(JSON.stringify({ dirty: dirty, files: files }));
637
639
  } catch (e) {
638
640
  res.writeHead(200, { "Content-Type": "application/json" });
639
641
  res.end(JSON.stringify({ dirty: false }));
@@ -21,6 +21,7 @@ function attachLoop(ctx) {
21
21
  var sendTo = ctx.sendTo;
22
22
  var sendToSession = ctx.sendToSession;
23
23
  var pushModule = ctx.pushModule;
24
+ var notificationsModule = ctx.notificationsModule;
24
25
  var getHubSchedules = ctx.getHubSchedules;
25
26
  var getLinuxUserForSession = ctx.getLinuxUserForSession;
26
27
  var onProcessingChanged = ctx.onProcessingChanged;
@@ -134,13 +135,16 @@ function attachLoop(ctx) {
134
135
  var _ld = path.join(_loopsBase, _loopDirs[_li]);
135
136
  try {
136
137
  fs.accessSync(path.join(_ld, "PROMPT.md"));
137
- fs.accessSync(path.join(_ld, "JUDGE.md"));
138
138
  fs.accessSync(path.join(_ld, "LOOP.json"));
139
+ var _loopCfg = JSON.parse(fs.readFileSync(path.join(_ld, "LOOP.json"), "utf8"));
140
+ var _isSimple = _loopCfg.loopMode === "simple";
141
+ if (!_isSimple) fs.accessSync(path.join(_ld, "JUDGE.md"));
139
142
  // Found a completed loop — recover to approval phase
140
143
  loopState.loopId = _loopDirs[_li];
141
144
  loopState.phase = "approval";
142
- var _loopCfg = JSON.parse(fs.readFileSync(path.join(_ld, "LOOP.json"), "utf8"));
143
145
  loopState.maxIterations = _loopCfg.maxIterations || 20;
146
+ if (!loopState.wizardData) loopState.wizardData = {};
147
+ loopState.wizardData.loopMode = _loopCfg.loopMode || "judge";
144
148
  saveLoopState();
145
149
  console.log("[ralph-loop] Recovered orphaned loop: " + _loopDirs[_li]);
146
150
  break;
@@ -177,7 +181,8 @@ function attachLoop(ctx) {
177
181
  var hasJudge = false;
178
182
  try { fs.accessSync(path.join(dir, "PROMPT.md")); hasPrompt = true; } catch (e) {}
179
183
  try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
180
- return hasPrompt && hasJudge;
184
+ var isSimple = loopState.wizardData && loopState.wizardData.loopMode === "simple";
185
+ return isSimple ? hasPrompt : (hasPrompt && hasJudge);
181
186
  }
182
187
 
183
188
  // .claude/ directory watcher for PROMPT.md / JUDGE.md
@@ -223,16 +228,18 @@ function attachLoop(ctx) {
223
228
  try { fs.accessSync(path.join(dir, "JUDGE.md")); hasJudge = true; } catch (e) {}
224
229
  try { fs.accessSync(path.join(dir, "LOOP.json")); hasLoopJson = true; } catch (e) {}
225
230
  }
231
+ var isSimple = loopState.wizardData && loopState.wizardData.loopMode === "simple";
232
+ var bothReady = isSimple ? hasPrompt : (hasPrompt && hasJudge);
226
233
  send({
227
234
  type: "ralph_files_status",
228
235
  promptReady: hasPrompt,
229
236
  judgeReady: hasJudge,
230
237
  loopJsonReady: hasLoopJson,
231
- bothReady: hasPrompt && hasJudge,
238
+ bothReady: bothReady,
232
239
  taskId: loopState.loopId,
233
240
  });
234
- // Auto-transition to approval phase when both files appear
235
- if (hasPrompt && hasJudge && loopState.phase === "crafting") {
241
+ // Auto-transition to approval phase when files are ready
242
+ if (bothReady && loopState.phase === "crafting") {
236
243
  loopState.phase = "approval";
237
244
  saveLoopState();
238
245
 
@@ -298,7 +305,10 @@ function attachLoop(ctx) {
298
305
  // Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
299
306
  loopState.loopId = record.id;
300
307
  loopState.loopFilesId = loopFilesId;
301
- loopState.wizardData = null;
308
+ // Restore loopMode from LOOP.json so simple loops work correctly on trigger
309
+ var _triggerCfg = {};
310
+ try { _triggerCfg = JSON.parse(fs.readFileSync(path.join(recDir, "LOOP.json"), "utf8")); } catch (e) {}
311
+ loopState.wizardData = { loopMode: _triggerCfg.loopMode || "judge" };
302
312
  activeRegistryId = record.id;
303
313
  console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopState.loopId + ")");
304
314
  send({ type: "schedule_run_started", recordId: record.id });
@@ -356,12 +366,17 @@ function attachLoop(ctx) {
356
366
  loopConfig = JSON.parse(fs.readFileSync(path.join(dir, "LOOP.json"), "utf8"));
357
367
  } catch (e) {}
358
368
 
369
+ var isSimple = loopState.wizardData && loopState.wizardData.loopMode === "simple";
359
370
  loopState.active = true;
360
371
  loopState.phase = "executing";
361
372
  loopState.promptText = promptText;
362
- loopState.judgeText = judgeText;
373
+ loopState.judgeText = isSimple ? null : judgeText;
363
374
  loopState.iteration = 0;
364
- loopState.maxIterations = judgeText ? ((loopOpts.maxIterations >= 1 ? loopOpts.maxIterations : null) || loopConfig.maxIterations || 20) : 1;
375
+ if (isSimple) {
376
+ loopState.maxIterations = (loopOpts.maxIterations >= 1 ? loopOpts.maxIterations : null) || loopConfig.maxIterations || 5;
377
+ } else {
378
+ loopState.maxIterations = judgeText ? ((loopOpts.maxIterations >= 1 ? loopOpts.maxIterations : null) || loopConfig.maxIterations || 20) : 1;
379
+ }
365
380
  loopState.baseCommit = baseCommit;
366
381
  loopState.currentSessionId = null;
367
382
  loopState.judgeSessionId = null;
@@ -440,7 +455,15 @@ function attachLoop(ctx) {
440
455
  setTimeout(function() { runNextIteration(); }, 2000);
441
456
  return;
442
457
  }
443
- if (loopState.judgeText && loopState.maxIterations > 1) {
458
+ var _isSimple = loopState.wizardData && loopState.wizardData.loopMode === "simple";
459
+ if (_isSimple) {
460
+ // Simple mode: no judge, proceed to next iteration or finish
461
+ if (loopState.iteration >= loopState.maxIterations) {
462
+ finishLoop("complete");
463
+ } else {
464
+ setTimeout(function() { runNextIteration(); }, 1000);
465
+ }
466
+ } else if (loopState.judgeText && loopState.maxIterations > 1) {
444
467
  runJudge();
445
468
  } else {
446
469
  finishLoop("pass");
@@ -648,19 +671,23 @@ function attachLoop(ctx) {
648
671
  }
649
672
 
650
673
  if (pushModule) {
651
- var body = reason === "pass"
652
- ? "Task completed after " + loopState.iteration + " iteration(s)"
674
+ var _finishBody = reason === "pass" || reason === "complete"
675
+ ? "Completed after " + loopState.iteration + " iteration(s)"
653
676
  : reason === "max_iterations"
654
677
  ? "Reached max iterations (" + loopState.maxIterations + ")"
655
- : reason === "stopped"
656
- ? "Loop stopped by user"
657
- : "Loop ended due to error";
678
+ : reason === "stopped" ? "Stopped by user" : "Ended due to error";
658
679
  pushModule.sendPush({
659
- type: "done",
660
- slug: slug,
661
- title: "Ralph Loop Complete",
662
- body: body,
663
- tag: "ralph-loop-done",
680
+ type: "done", slug: slug, title: "Loop Complete", body: _finishBody, tag: "ralph-loop-done",
681
+ });
682
+ }
683
+
684
+ if (notificationsModule) {
685
+ notificationsModule.notify("loop_complete", {
686
+ reason: reason,
687
+ name: loopState.name,
688
+ iterations: loopState.iteration,
689
+ maxIterations: loopState.maxIterations,
690
+ sessionId: loopState.currentSessionId,
664
691
  });
665
692
  }
666
693
  }
@@ -683,14 +710,19 @@ function attachLoop(ctx) {
683
710
  saveLoopState();
684
711
  return;
685
712
  }
686
- try {
687
- loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
688
- } catch (e) {
689
- console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
690
- loopState.active = false;
691
- loopState.phase = "idle";
692
- saveLoopState();
693
- return;
713
+ var _isSimpleResume = loopState.wizardData && loopState.wizardData.loopMode === "simple";
714
+ if (!_isSimpleResume) {
715
+ try {
716
+ loopState.judgeText = fs.readFileSync(path.join(dir, "JUDGE.md"), "utf8");
717
+ } catch (e) {
718
+ console.error("[ralph-loop] Cannot resume: missing JUDGE.md");
719
+ loopState.active = false;
720
+ loopState.phase = "idle";
721
+ saveLoopState();
722
+ return;
723
+ }
724
+ } else {
725
+ loopState.judgeText = null;
694
726
  }
695
727
  // Retry the interrupted iteration (runNextIteration will increment)
696
728
  if (loopState.iteration > 0) {
@@ -742,7 +774,7 @@ function attachLoop(ctx) {
742
774
  send({ type: "loop_scheduled", recordId: loopState.loopId, cron: loopState.wizardData.cron });
743
775
  return true;
744
776
  }
745
- startLoop();
777
+ startLoop({ maxIterations: msg.maxIterations });
746
778
  return true;
747
779
  }
748
780
 
@@ -753,7 +785,7 @@ function attachLoop(ctx) {
753
785
 
754
786
  if (msg.type === "ralph_wizard_complete") {
755
787
  var wData = msg.data || {};
756
- var maxIter = wData.maxIterations || 3;
788
+ var maxIter = wData.maxIterations || null;
757
789
  var wizardCron = wData.cron || null;
758
790
  var newLoopId = generateLoopId();
759
791
  loopState.loopId = newLoopId;
@@ -762,6 +794,9 @@ function attachLoop(ctx) {
762
794
  task: wData.task || "",
763
795
  maxIterations: maxIter,
764
796
  cron: wizardCron,
797
+ loopMode: wData.loopMode || "judge",
798
+ promptAuthor: wData.promptAuthor || "clay",
799
+ judgeAuthor: wData.judgeAuthor || null,
765
800
  };
766
801
  loopState.phase = "crafting";
767
802
  loopState.startedAt = Date.now();
@@ -784,7 +819,7 @@ function attachLoop(ctx) {
784
819
  try { fs.mkdirSync(lDir, { recursive: true }); } catch (e) {}
785
820
  var loopJsonPath = path.join(lDir, "LOOP.json");
786
821
  var tmpLoopJson = loopJsonPath + ".tmp";
787
- fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter }, null, 2));
822
+ fs.writeFileSync(tmpLoopJson, JSON.stringify({ maxIterations: maxIter, loopMode: wData.loopMode || "judge" }, null, 2));
788
823
  fs.renameSync(tmpLoopJson, loopJsonPath);
789
824
 
790
825
  var craftName = (loopState.wizardData && loopState.wizardData.name) || "";
@@ -804,19 +839,15 @@ function attachLoop(ctx) {
804
839
  var tmpJudge = judgePath + ".tmp";
805
840
  fs.writeFileSync(tmpJudge, wData.judgeText);
806
841
  fs.renameSync(tmpJudge, judgePath);
807
- } else if (!recordSource) {
808
- // Scheduled task with no judge: force single iteration and go to approval
809
- var singleJson = loopJsonPath + ".tmp2";
810
- fs.writeFileSync(singleJson, JSON.stringify({ maxIterations: 1 }, null, 2));
811
- fs.renameSync(singleJson, loopJsonPath);
812
-
842
+ } else if (wData.loopMode === "simple" || !recordSource) {
843
+ // Simple loop or scheduled task with no judge: go straight to approval
813
844
  loopState.phase = "approval";
814
845
  saveLoopState();
815
846
  send({ type: "ralph_phase", phase: "approval", source: recordSource, wizardData: loopState.wizardData });
816
847
  send({ type: "ralph_files_status", promptReady: true, judgeReady: false, bothReady: true });
817
848
  return true;
818
849
  } else {
819
- // Ralph with no judge: start a crafting session to create JUDGE.md
850
+ // Ralph with judge mode but no judge provided: start a crafting session to create JUDGE.md
820
851
  loopState.phase = "crafting";
821
852
  saveLoopState();
822
853
 
@@ -863,13 +894,39 @@ function attachLoop(ctx) {
863
894
  return true;
864
895
  }
865
896
 
866
- // Default: "draft" mode — Clay crafts both PROMPT.md and JUDGE.md
867
- var craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
868
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
869
- "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
870
- "that a future autonomous session will execute.\n\n" +
871
- "## Task\n" + (wData.task || "") +
872
- "\n\n## Loop Directory\n" + lDir;
897
+ // Default: "draft" mode — Clay crafts files via the clay-ralph skill
898
+ var _draftIsSimple = wData.loopMode === "simple";
899
+ var craftingPrompt;
900
+ if (_draftIsSimple) {
901
+ craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
902
+ "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
903
+ "This is a Simple Loop (no judge). Your job is to create ONLY a PROMPT.md file " +
904
+ "that a future autonomous session will execute. Do NOT create a JUDGE.md file.\n\n" +
905
+ "## Task\n" + (wData.task || "") +
906
+ "\n\n## Loop Directory\n" + lDir;
907
+ } else if (wData.judgeAuthor === "me") {
908
+ craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
909
+ "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
910
+ "The user will provide their own JUDGE.md, so create ONLY a PROMPT.md file " +
911
+ "that a future autonomous session will execute. Do NOT create a JUDGE.md file.\n\n" +
912
+ "## Task\n" + (wData.task || "") +
913
+ "\n\n## Loop Directory\n" + lDir;
914
+ } else {
915
+ craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
916
+ "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
917
+ "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
918
+ "that a future autonomous session will execute.\n\n" +
919
+ "## Task\n" + (wData.task || "") +
920
+ "\n\n## Loop Directory\n" + lDir;
921
+ }
922
+
923
+ // Pre-write user-provided files before crafting starts
924
+ if (wData.judgeText && wData.judgeAuthor === "me") {
925
+ var _judgePathDraft = path.join(lDir, "JUDGE.md");
926
+ var _tmpJudgeDraft = _judgePathDraft + ".tmp";
927
+ fs.writeFileSync(_tmpJudgeDraft, wData.judgeText);
928
+ fs.renameSync(_tmpJudgeDraft, _judgePathDraft);
929
+ }
873
930
 
874
931
  // Create a new session for crafting
875
932
  var craftingSession = sm.createSession();
@@ -1088,10 +1145,11 @@ function attachLoop(ctx) {
1088
1145
  // --- Connection state: send loop state to newly connected client ---
1089
1146
  function sendConnectionState(ws) {
1090
1147
  // Ralph Loop availability
1148
+ var _connIsSimple = loopState.wizardData && loopState.wizardData.loopMode === "simple";
1091
1149
  var hasLoopFiles = false;
1092
1150
  try {
1093
1151
  fs.accessSync(path.join(cwd, ".claude", "PROMPT.md"));
1094
- fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
1152
+ if (!_connIsSimple) fs.accessSync(path.join(cwd, ".claude", "JUDGE.md"));
1095
1153
  hasLoopFiles = true;
1096
1154
  } catch (e) {}
1097
1155
  // Also check loop directory files
@@ -1100,7 +1158,7 @@ function attachLoop(ctx) {
1100
1158
  if (_avDir) {
1101
1159
  try {
1102
1160
  fs.accessSync(path.join(_avDir, "PROMPT.md"));
1103
- fs.accessSync(path.join(_avDir, "JUDGE.md"));
1161
+ if (!_connIsSimple) fs.accessSync(path.join(_avDir, "JUDGE.md"));
1104
1162
  hasLoopFiles = true;
1105
1163
  } catch (e) {}
1106
1164
  }
@@ -1115,11 +1173,14 @@ function attachLoop(ctx) {
1115
1173
  });
1116
1174
 
1117
1175
  // Ralph phase state
1176
+ // Derive source from wizardData for reconnect (so client can distinguish ralph vs task)
1177
+ var _connSource = (loopState.wizardData && loopState.wizardData.source === "task") ? null : "ralph";
1118
1178
  sendTo(ws, {
1119
1179
  type: "ralph_phase",
1120
1180
  phase: loopState.phase,
1121
1181
  wizardData: loopState.wizardData,
1122
1182
  craftingSessionId: loopState.craftingSessionId || null,
1183
+ source: _connSource,
1123
1184
  });
1124
1185
  if (loopState.phase === "crafting" || loopState.phase === "approval") {
1125
1186
  var _hasPrompt = false;
@@ -1129,11 +1190,12 @@ function attachLoop(ctx) {
1129
1190
  try { fs.accessSync(path.join(_lDir, "PROMPT.md")); _hasPrompt = true; } catch (e) {}
1130
1191
  try { fs.accessSync(path.join(_lDir, "JUDGE.md")); _hasJudge = true; } catch (e) {}
1131
1192
  }
1193
+ var _connBothReady = _connIsSimple ? _hasPrompt : (_hasPrompt && _hasJudge);
1132
1194
  sendTo(ws, {
1133
1195
  type: "ralph_files_status",
1134
1196
  promptReady: _hasPrompt,
1135
1197
  judgeReady: _hasJudge,
1136
- bothReady: _hasPrompt && _hasJudge,
1198
+ bothReady: _connBothReady,
1137
1199
  taskId: loopState.loopId,
1138
1200
  });
1139
1201
  }
@@ -0,0 +1,210 @@
1
+ // project-notifications.js - Global notification queue, event-based creation
2
+ // Single instance shared by all projects. All formatting logic lives here.
3
+ // Follows attachXxx(ctx) pattern per MODULE_MAP.md
4
+
5
+ var fs = require("fs");
6
+ var path = require("path");
7
+ var config = require("./config");
8
+
9
+ var NOTIF_FILE = path.join(config.CONFIG_DIR, "notifications.json");
10
+ var REMINDER_INTERVAL = 60 * 60 * 1000; // 1 hour
11
+
12
+ function generateId() {
13
+ var rand = Math.random().toString(36).substring(2, 8);
14
+ return "n_" + Date.now() + "_" + rand;
15
+ }
16
+
17
+ // ========================================================
18
+ // Event -> notification formatters
19
+ // ========================================================
20
+
21
+ var formatters = {
22
+ loop_complete: function (data) {
23
+ var reason = data.reason || "complete";
24
+ var body = (reason === "pass" || reason === "complete")
25
+ ? "Completed after " + (data.iterations || 0) + " iteration(s)"
26
+ : reason === "max_iterations"
27
+ ? "Reached max iterations (" + (data.maxIterations || "?") + ")"
28
+ : reason === "stopped"
29
+ ? "Stopped by user"
30
+ : "Ended due to error";
31
+ return {
32
+ type: reason === "error" ? "loop_error" : "loop_complete",
33
+ title: (data.name || "Loop") + " " + (reason === "error" ? "failed" : "complete"),
34
+ body: body,
35
+ meta: { reason: reason, iterations: data.iterations },
36
+ };
37
+ },
38
+
39
+ response_done: function (data) {
40
+ return {
41
+ type: "response_done",
42
+ title: data.title || "Response ready",
43
+ body: data.preview || "",
44
+ };
45
+ },
46
+
47
+ permission_request: function (data) {
48
+ return {
49
+ type: "permission_request",
50
+ title: data.title || "Permission requested",
51
+ body: data.body || "",
52
+ meta: { requestId: data.requestId, toolName: data.toolName, toolInput: data.toolInput || null },
53
+ };
54
+ },
55
+
56
+ mate_dm: function (data) {
57
+ return {
58
+ type: "mate_dm",
59
+ title: data.senderName || "Message",
60
+ body: data.preview || "Sent you a message",
61
+ };
62
+ },
63
+ };
64
+
65
+ // ========================================================
66
+ // Module (global singleton)
67
+ // ========================================================
68
+
69
+ function attachNotifications(ctx) {
70
+ var broadcastAll = ctx.broadcastAll;
71
+ var pushModule = ctx.pushModule;
72
+
73
+ var notifications = loadNotifications();
74
+ var reminderTimer = null;
75
+
76
+ function loadNotifications() {
77
+ try {
78
+ var raw = fs.readFileSync(NOTIF_FILE, "utf8");
79
+ var parsed = JSON.parse(raw);
80
+ if (Array.isArray(parsed)) return parsed;
81
+ } catch (e) {}
82
+ return [];
83
+ }
84
+
85
+ function saveNotifications() {
86
+ var tmp = NOTIF_FILE + ".tmp";
87
+ try {
88
+ fs.writeFileSync(tmp, JSON.stringify(notifications, null, 2));
89
+ fs.renameSync(tmp, NOTIF_FILE);
90
+ } catch (e) {
91
+ console.error("[notifications] Failed to save:", e.message);
92
+ }
93
+ }
94
+
95
+ function getUnreadCount() {
96
+ return notifications.length;
97
+ }
98
+
99
+ // --- Core API ---
100
+
101
+ function notify(event, data) {
102
+ var formatter = formatters[event];
103
+ if (!formatter) {
104
+ console.warn("[notifications] Unknown event: " + event);
105
+ return null;
106
+ }
107
+ var formatted = formatter(data || {});
108
+ if (!formatted) return null;
109
+
110
+ var notif = {
111
+ id: generateId(),
112
+ type: formatted.type || event,
113
+ title: formatted.title || "",
114
+ body: formatted.body || "",
115
+ slug: data.slug || "",
116
+ sessionId: data.sessionId || null,
117
+ mateId: data.mateId || null,
118
+ ownerId: data.ownerId || null,
119
+ createdAt: Date.now(),
120
+ meta: formatted.meta || {},
121
+ };
122
+ notifications.unshift(notif);
123
+ saveNotifications();
124
+ broadcastAll({
125
+ type: "notification_created",
126
+ notification: notif,
127
+ unreadCount: getUnreadCount(),
128
+ });
129
+ return notif;
130
+ }
131
+
132
+ function dismiss(ids) {
133
+ var before = notifications.length;
134
+ notifications = notifications.filter(function(n) { return ids.indexOf(n.id) === -1; });
135
+ if (notifications.length !== before) {
136
+ saveNotifications();
137
+ broadcastAll({
138
+ type: "notification_dismissed",
139
+ ids: ids,
140
+ unreadCount: getUnreadCount(),
141
+ });
142
+ }
143
+ }
144
+
145
+ function dismissAll() {
146
+ if (notifications.length > 0) {
147
+ notifications = [];
148
+ saveNotifications();
149
+ broadcastAll({ type: "notification_dismissed_all", unreadCount: 0 });
150
+ }
151
+ }
152
+
153
+ // --- Periodic reminder ---
154
+
155
+ function startReminder() {
156
+ if (reminderTimer) return;
157
+ reminderTimer = setInterval(function () {
158
+ var count = getUnreadCount();
159
+ if (count > 0 && pushModule) {
160
+ pushModule.sendPush({
161
+ type: "reminder",
162
+ title: "Clay",
163
+ body: count + " unread notification" + (count > 1 ? "s" : ""),
164
+ tag: "clay-notif-reminder",
165
+ });
166
+ }
167
+ }, REMINDER_INTERVAL);
168
+ }
169
+
170
+ function stopReminder() {
171
+ if (reminderTimer) { clearInterval(reminderTimer); reminderTimer = null; }
172
+ }
173
+
174
+ startReminder();
175
+
176
+ // --- Connection state (called per-client with sendTo) ---
177
+
178
+ function sendConnectionState(ws, sendTo) {
179
+ if (!sendTo) return;
180
+ sendTo(ws, {
181
+ type: "notifications_state",
182
+ notifications: notifications,
183
+ unreadCount: getUnreadCount(),
184
+ });
185
+ }
186
+
187
+ // --- Message handler ---
188
+
189
+ function handleNotificationMessage(ws, msg) {
190
+ if (msg.type === "notification_dismiss") {
191
+ dismiss(msg.ids || []);
192
+ return true;
193
+ }
194
+ if (msg.type === "notification_dismiss_all") {
195
+ dismissAll();
196
+ return true;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ return {
202
+ notify: notify,
203
+ getUnreadCount: getUnreadCount,
204
+ sendConnectionState: sendConnectionState,
205
+ handleNotificationMessage: handleNotificationMessage,
206
+ stopReminder: stopReminder,
207
+ };
208
+ }
209
+
210
+ module.exports = { attachNotifications: attachNotifications };
@@ -681,12 +681,15 @@ function attachSessions(ctx) {
681
681
  }
682
682
 
683
683
  if (msg.type === "permission_response") {
684
- var session = getSessionForWs(ws);
685
- if (!session) return true;
686
684
  var requestId = msg.requestId;
687
685
  var decision = msg.decision;
686
+ // Look up session by requestId index (O(1)), fall back to active session
687
+ var sessionId = sm.permissionRequestIndex[requestId];
688
+ var session = sessionId ? sm.sessions.get(sessionId) : getSessionForWs(ws);
689
+ if (!session) return true;
688
690
  var pending = session.pendingPermissions[requestId];
689
691
  if (!pending) return true;
692
+ delete sm.permissionRequestIndex[requestId];
690
693
  delete session.pendingPermissions[requestId];
691
694
  onProcessingChanged(); // update cross-project permission badge
692
695
 
@@ -268,7 +268,7 @@ function attachUserMessage(ctx) {
268
268
  nowSession.scheduledMessage = null;
269
269
  console.log("[project] Scheduled message sent immediately for session " + nowSession.localId);
270
270
  sm.sendAndRecord(nowSession, { type: "scheduled_message_sent" });
271
- var userMsg = { type: "user_message", text: schedText };
271
+ var userMsg = { type: "user_message", text: schedText, _ts: Date.now() };
272
272
  nowSession.history.push(userMsg);
273
273
  sm.appendToSessionFile(nowSession, userMsg);
274
274
  sendToSession(nowSession.localId, userMsg);
@@ -320,6 +320,7 @@ function attachUserMessage(ctx) {
320
320
  if (msg.pastes && msg.pastes.length > 0) {
321
321
  userMsg2.pastes = msg.pastes;
322
322
  }
323
+ if (!userMsg2._ts) userMsg2._ts = Date.now();
323
324
  session.history.push(userMsg2);
324
325
  sm.appendToSessionFile(session, userMsg2);
325
326
  sendToSessionOthers(ws, session.localId, hydrateImageRefs(userMsg2));