clay-server 4.1.0-beta.4 → 4.1.0-beta.6

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 (91) hide show
  1. package/lib/autonomous-run-record.js +32 -0
  2. package/lib/autonomous-run-scheduler.js +92 -0
  3. package/lib/durable-scheduler-record.js +61 -0
  4. package/lib/durable-scheduler-store.js +185 -0
  5. package/lib/durable-scheduler.js +495 -0
  6. package/lib/loop-cron.js +47 -0
  7. package/lib/loop-durable-schedule.js +159 -0
  8. package/lib/loop-guidance.js +35 -0
  9. package/lib/loop-registry-occurrence.js +76 -0
  10. package/lib/project-autonomous-run.js +498 -0
  11. package/lib/project-connection.js +4 -1
  12. package/lib/project-full-access.js +86 -0
  13. package/lib/project-http.js +3 -1
  14. package/lib/project-loop-interview.js +163 -0
  15. package/lib/project-loop.js +150 -64
  16. package/lib/project-pair-autonomous-stop.js +16 -0
  17. package/lib/project-pair-owned-stop.js +18 -0
  18. package/lib/project-pair-task-control.js +14 -1
  19. package/lib/project-scheduled-messages.js +316 -0
  20. package/lib/project-scheduled-task-execution.js +236 -0
  21. package/lib/project-scheduled-tasks.js +483 -0
  22. package/lib/project-session-pair.js +16 -19
  23. package/lib/project-sessions.js +60 -28
  24. package/lib/project-user-message.js +24 -18
  25. package/lib/project.js +260 -52
  26. package/lib/public/app.js +60 -23
  27. package/lib/public/css/home-hub.css +15 -2
  28. package/lib/public/css/home-sidebar.css +50 -2
  29. package/lib/public/css/input.css +71 -0
  30. package/lib/public/css/mates.css +0 -1
  31. package/lib/public/css/pane.css +58 -7
  32. package/lib/public/css/rewind.css +11 -2
  33. package/lib/public/css/scheduled-tasks.css +123 -0
  34. package/lib/public/css/sticky-notes.css +24 -4
  35. package/lib/public/index.html +19 -134
  36. package/lib/public/modules/app-loop-ui.js +4 -8
  37. package/lib/public/modules/app-messages.js +22 -6
  38. package/lib/public/modules/app-projects.js +2 -0
  39. package/lib/public/modules/app-rate-limit.js +8 -5
  40. package/lib/public/modules/autonomous-run.js +285 -0
  41. package/lib/public/modules/home-sidebar.js +16 -8
  42. package/lib/public/modules/home-surface.js +5 -0
  43. package/lib/public/modules/input.js +5 -4
  44. package/lib/public/modules/loop-interview.js +151 -0
  45. package/lib/public/modules/project-logs.js +2 -2
  46. package/lib/public/modules/scheduled-message-state.js +46 -0
  47. package/lib/public/modules/scheduled-task-edit-state.js +7 -0
  48. package/lib/public/modules/scheduled-task-engine.js +98 -0
  49. package/lib/public/modules/scheduled-task-form.js +122 -0
  50. package/lib/public/modules/scheduled-task-recurrence.js +46 -0
  51. package/lib/public/modules/scheduled-task-schedule-rule.js +115 -0
  52. package/lib/public/modules/scheduled-tasks.js +468 -0
  53. package/lib/public/modules/scheduler-config.js +6 -52
  54. package/lib/public/modules/scheduler.js +9 -21
  55. package/lib/public/modules/sidebar.js +0 -11
  56. package/lib/public/modules/split-view.js +2 -0
  57. package/lib/public/modules/sticky-notes-browser.js +1 -1
  58. package/lib/public/modules/sticky-notes.js +117 -4
  59. package/lib/public/modules/tool-palette-order.js +2 -3
  60. package/lib/public/modules/tools.js +5 -2
  61. package/lib/public/style.css +1 -0
  62. package/lib/record-revision.js +11 -0
  63. package/lib/schedule-rule.js +151 -0
  64. package/lib/schedule-validation.js +124 -0
  65. package/lib/scheduled-run-state.js +87 -0
  66. package/lib/scheduled-task-records.js +127 -0
  67. package/lib/scheduler.js +179 -196
  68. package/lib/sdk-bridge.js +123 -11
  69. package/lib/sdk-message-processor.js +3 -0
  70. package/lib/server-durable-scheduler.js +26 -0
  71. package/lib/server.js +22 -0
  72. package/lib/session-pair-factory.js +42 -4
  73. package/lib/session-pair-query-start.js +22 -0
  74. package/lib/session-query-tool-bridge.js +52 -0
  75. package/lib/session-split-groups.js +35 -1
  76. package/lib/sessions.js +51 -3
  77. package/lib/users-home-surface-preferences.js +2 -1
  78. package/lib/users-scheduled-task-preferences.js +44 -0
  79. package/lib/users.js +6 -0
  80. package/lib/ws-schema.js +25 -0
  81. package/lib/yoke/adapters/antigravity.js +1 -0
  82. package/lib/yoke/adapters/claude-worker.js +7 -4
  83. package/lib/yoke/adapters/claude.js +31 -1
  84. package/lib/yoke/adapters/codex.js +3 -0
  85. package/lib/yoke/claude-user-input.js +2 -0
  86. package/lib/yoke/index.js +2 -0
  87. package/lib/yoke/mcp-bridge-server.js +7 -1
  88. package/lib/yoke/session-tool-transport.js +53 -0
  89. package/lib/yoke/user-input.js +21 -7
  90. package/package.json +1 -1
  91. package/lib/public/modules/app-loop-wizard.js +0 -439
@@ -0,0 +1,86 @@
1
+ // Shared, transactional control for Clay-managed per-session Skip Permissions.
2
+
3
+ function createFullAccessService(ctx) {
4
+ var sm = ctx.sm;
5
+ var transitions = new Map();
6
+
7
+ function serialize(session, action) {
8
+ var prior = transitions.get(session.localId) || Promise.resolve();
9
+ var next = prior.catch(function () {}).then(action);
10
+ transitions.set(session.localId, next);
11
+ return next.finally(function () {
12
+ if (transitions.get(session.localId) === next) transitions.delete(session.localId);
13
+ });
14
+ }
15
+
16
+ function snapshot(session) {
17
+ return {
18
+ permissionMode: session.permissionMode || null,
19
+ permissionModeBeforeFullAccess: session.permissionModeBeforeFullAccess || null,
20
+ enabled: session.permissionMode === "bypassPermissions",
21
+ };
22
+ }
23
+
24
+ function notify(session) {
25
+ if (typeof sm.sendToSession === "function") sm.sendToSession(session, { type: "session_full_access_changed", id: session.localId,
26
+ enabled: session.permissionMode === "bypassPermissions", permissionMode: session.permissionMode });
27
+ }
28
+
29
+ async function applyNativeMode(session, enabled) {
30
+ if (!session.queryInstance || typeof session.queryInstance.setPermissionMode !== "function") return;
31
+ // Clay's common permission callback remains the approval authority.
32
+ await session.queryInstance.setPermissionMode(enabled ? "default" : session.permissionMode);
33
+ }
34
+
35
+ async function applyEnabled(session, enabled) {
36
+ var previous = snapshot(session);
37
+ if (previous.enabled === !!enabled) return previous;
38
+ if (enabled) {
39
+ session.permissionModeBeforeFullAccess = session.permissionMode || sm.currentPermissionMode || "default";
40
+ session.permissionMode = "bypassPermissions";
41
+ } else {
42
+ session.permissionMode = session.permissionModeBeforeFullAccess || "default";
43
+ session.permissionModeBeforeFullAccess = null;
44
+ }
45
+ try {
46
+ await applyNativeMode(session, !!enabled);
47
+ sm.saveSessionFile(session);
48
+ sm.broadcastSessionList();
49
+ notify(session);
50
+ return previous;
51
+ } catch (error) {
52
+ session.permissionMode = previous.permissionMode;
53
+ session.permissionModeBeforeFullAccess = previous.permissionModeBeforeFullAccess;
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ function setEnabled(session, enabled) {
59
+ return serialize(session, function () { return applyEnabled(session, enabled); });
60
+ }
61
+
62
+ async function applyRestore(session, prior) {
63
+ if (!prior) return;
64
+ var current = snapshot(session);
65
+ session.permissionMode = prior.permissionMode;
66
+ session.permissionModeBeforeFullAccess = prior.permissionModeBeforeFullAccess;
67
+ try {
68
+ await applyNativeMode(session, prior.enabled);
69
+ sm.saveSessionFile(session);
70
+ sm.broadcastSessionList();
71
+ notify(session);
72
+ } catch (error) {
73
+ session.permissionMode = current.permissionMode;
74
+ session.permissionModeBeforeFullAccess = current.permissionModeBeforeFullAccess;
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ function restore(session, prior) {
80
+ return serialize(session, function () { return applyRestore(session, prior); });
81
+ }
82
+
83
+ return { restore: restore, setEnabled: setEnabled, snapshot: snapshot };
84
+ }
85
+
86
+ module.exports = { createFullAccessService: createFullAccessService };
@@ -898,7 +898,9 @@ function attachHTTP(ctx) {
898
898
  }
899
899
  var sessionId = Number(body.sessionId);
900
900
  if (!Number.isInteger(sessionId)) sessionId = null;
901
- var handler = getMcpBridgeHandler(sessionId, body.sessionOnly === true);
901
+ var queryGeneration = Number(body.queryGeneration);
902
+ if (!Number.isInteger(queryGeneration)) queryGeneration = null;
903
+ var handler = getMcpBridgeHandler(sessionId, body.sessionOnly === true, queryGeneration);
902
904
  if (!handler) {
903
905
  res.writeHead(500, { "Content-Type": "application/json" });
904
906
  res.end('{"error":"MCP bridge handler unavailable"}');
@@ -0,0 +1,163 @@
1
+ var crypto = require("crypto");
2
+ var driverEligibility = require("./session-driver-eligibility");
3
+ var loopGuidance = require("./loop-guidance");
4
+
5
+ var MAX_OBJECTIVE = 20000;
6
+ var MAX_CRITERIA = 10;
7
+ var MAX_CRITERION = 1000;
8
+
9
+ function text(value, max) { return typeof value === "string" ? value.trim().substring(0, max) : ""; }
10
+ function criteria(value) { return (Array.isArray(value) ? value : []).slice(0, MAX_CRITERIA).map(function (item) { return text(item, MAX_CRITERION); }).filter(Boolean); }
11
+ function activeInterviewId(session) {
12
+ var history = Array.isArray(session && session.history) ? session.history : [];
13
+ var current = null;
14
+ for (var i = 0; i < history.length; i++) {
15
+ var entry = history[i];
16
+ if (entry && entry.source === "loop_interview") current = entry.interviewId || null;
17
+ if (entry && (entry.source === "loop_interview_cancel" || entry.source === "loop_interview_close") && entry.interviewId === current) current = null;
18
+ }
19
+ return current;
20
+ }
21
+ function brief(value) {
22
+ if (!value || typeof value !== "object") return null;
23
+ var objective = text(value.objective, MAX_OBJECTIVE);
24
+ var successCriteria = criteria(value.successCriteria);
25
+ if (!objective || successCriteria.length === 0) return null;
26
+ return { version: 1, id: text(value.id, 200) || crypto.randomUUID(), objective: objective,
27
+ successCriteria: successCriteria, maxContinuations: Math.max(1, Math.min(100, Math.floor(Number(value.maxContinuations) || 10))),
28
+ maxMinutes: Math.max(1, Math.min(1440, Math.floor(Number(value.maxMinutes) || 60))), createdAt: Number(value.createdAt) || Date.now() };
29
+ }
30
+
31
+ function attachLoopInterview(ctx) {
32
+ var sm = ctx.sm;
33
+ var sendTo = ctx.sendTo;
34
+ var autonomousRun = ctx.autonomousRun;
35
+ function eligible(session) { return !!(session && !ctx.isMate && (session.runtimeMode || session.mode) === "gui" && driverEligibility.isEligibleDriverSession(session) && !ctx.isDriverOperatedSession(session)); }
36
+ function runActive(session) { return !!(session && session.autonomousRun && ["armed", "running", "reviewing", "waiting-worker", "waiting-user", "paused"].indexOf(session.autonomousRun.state) !== -1); }
37
+ function hasInterview(session, requestId) {
38
+ var history = Array.isArray(session && session.history) ? session.history : [];
39
+ for (var i = history.length - 1; i >= 0; i--) if (history[i] && history[i].source === "loop_interview" && history[i].requestId === requestId) return true;
40
+ return false;
41
+ }
42
+ function currentInterview(session) {
43
+ return activeInterviewId(session);
44
+ }
45
+ function interviewActive(session) {
46
+ return !!currentInterview(session);
47
+ }
48
+ function startInterview(ws, msg) {
49
+ var session = ctx.getSessionForWs(ws);
50
+ var requestId = text(msg.requestId, 200);
51
+ var result = { type: "loop_interview_start_result", sessionId: session && session.localId || msg.sessionId || null, requestId: requestId, ok: false };
52
+ if (!session || !requestId || Number(msg.sessionId) !== Number(session.localId) || (ctx.canAccess && !ctx.canAccess(ws, session))) result.error = "This session is no longer available.";
53
+ else if (hasInterview(session, requestId)) { result.ok = true; result.duplicate = true; }
54
+ else if (!eligible(session)) result.error = "Loop interviews are available only in a Driver project chat.";
55
+ else if (runActive(session)) result.error = "Finish the active Loop run before starting another interview.";
56
+ else if (session.isProcessing || session._queryStarting) result.error = "Wait for the current task to finish before starting a Loop interview.";
57
+ else {
58
+ session.loopInterviewBrief = null;
59
+ var prompt = loopGuidance.interviewPrompt();
60
+ var interviewId = crypto.randomUUID();
61
+ var entry = { type: "user_message", text: "[Loop interview started]", source: "loop_interview", interviewId: interviewId, requestId: requestId, _ts: Date.now() };
62
+ session.history.push(entry);
63
+ sm.appendToSessionFile(session, entry);
64
+ sm.saveSessionFile(session);
65
+ publish(session);
66
+ ctx.sendToSession(session.localId, entry);
67
+ session.isProcessing = true;
68
+ ctx.onProcessingChanged();
69
+ ctx.sendToSession(session.localId, { type: "status", status: "processing" });
70
+ try {
71
+ var started = ctx.sdk.startQuery(session, prompt, null, ctx.ensureProjectAccessForSession(session));
72
+ ctx.sendTo(ws, Object.assign({}, result, { ok: true }));
73
+ Promise.resolve(started).catch(function (error) {
74
+ var stillCurrent = ctx.getSessionForWs(ws) === session && currentInterview(session) === interviewId && hasInterview(session, requestId);
75
+ if (!stillCurrent) return;
76
+ session.isProcessing = false;
77
+ ctx.onProcessingChanged();
78
+ ctx.sendToSession(session.localId, { type: "status", status: "idle" });
79
+ sm.saveSessionFile(session);
80
+ ctx.sendTo(ws, Object.assign({}, result, { ok: false, error: "Loop interview could not start: " + (error.message || error) }));
81
+ });
82
+ return true;
83
+ } catch (error) {
84
+ session.isProcessing = false;
85
+ ctx.onProcessingChanged();
86
+ ctx.sendToSession(session.localId, { type: "status", status: "idle" });
87
+ sm.saveSessionFile(session);
88
+ result.error = "Loop interview could not start: " + (error.message || error);
89
+ }
90
+ }
91
+ ctx.sendTo(ws, result);
92
+ return true;
93
+ }
94
+ function publish(session) { sm.sendToSession(session, { type: "loop_interview_brief", sessionId: session.localId, brief: session.loopInterviewBrief || null }); }
95
+ function propose(session, interviewId, args) {
96
+ if (!eligible(session) || runActive(session) || !interviewId || currentInterview(session) !== interviewId) return Promise.resolve({ content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: "propose_loop is stale or unavailable outside the current Driver Loop interview." }) }] });
97
+ var next = brief(args);
98
+ if (!next) return Promise.resolve({ content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: "Provide an objective and at least one success criterion." }) }] });
99
+ next.interviewId = interviewId;
100
+ session.loopInterviewBrief = next;
101
+ sm.saveSessionFile(session);
102
+ publish(session);
103
+ return Promise.resolve({ content: [{ type: "text", text: JSON.stringify({ status: "proposed", brief: next }) }] });
104
+ }
105
+ function getToolDefs(session) {
106
+ if (!eligible(session) || runActive(session) || !interviewActive(session) || session.loopInterviewBrief) return [];
107
+ var interviewId = currentInterview(session);
108
+ return [{ name: "propose_loop", description: "Draft a bounded Loop execution brief for human review. This never starts execution or changes permissions.", inputSchema: { type: "object", properties: {
109
+ objective: { type: "string" }, successCriteria: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 10 },
110
+ maxContinuations: { type: "integer", minimum: 1, maximum: 100 }, maxMinutes: { type: "integer", minimum: 1, maximum: 1440 }
111
+ }, required: ["objective", "successCriteria"], additionalProperties: false }, handler: function (args) { return propose(session, interviewId, args || {}); } }];
112
+ }
113
+ function handleMessage(ws, msg) {
114
+ if (msg.type === "loop_interview_state") {
115
+ var stateSession = ctx.getSessionForWs(ws);
116
+ if (stateSession && Number(msg.sessionId) === Number(stateSession.localId) && (!ctx.canAccess || ctx.canAccess(ws, stateSession))) publish(stateSession);
117
+ return true;
118
+ }
119
+ if (msg.type === "loop_interview_start") return startInterview(ws, msg);
120
+ if (["loop_interview_start_run", "loop_interview_edit", "loop_interview_cancel"].indexOf(msg.type) === -1) return false;
121
+ var session = ctx.getSessionForWs(ws);
122
+ if (!session || Number(msg.sessionId) !== Number(session.localId) || (ctx.canAccess && !ctx.canAccess(ws, session)) || !eligible(session)) {
123
+ if (msg.type === "loop_interview_start_run") sendTo(ws, { type: "loop_interview_start_run_result", ok: false, sessionId: session && session.localId || msg.sessionId || null, requestId: text(msg.requestId, 200), error: "This session is no longer available." });
124
+ return true;
125
+ }
126
+ if (msg.type === "loop_interview_cancel") {
127
+ if (session.loopInterviewBrief && (text(msg.proposalId, 200) !== session.loopInterviewBrief.id || Number(msg.version) !== Number(session.loopInterviewBrief.version))) return true;
128
+ var cancelEntry = { type: "loop_interview_cancel", source: "loop_interview_cancel", interviewId: currentInterview(session), _ts: Date.now() };
129
+ session.history.push(cancelEntry); sm.appendToSessionFile(session, cancelEntry);
130
+ session.loopInterviewBrief = null; sm.saveSessionFile(session); publish(session); return true;
131
+ }
132
+ if (msg.type === "loop_interview_edit") {
133
+ if (session.loopInterviewBrief && text(msg.proposalId, 200) === session.loopInterviewBrief.id && Number(msg.version) === Number(session.loopInterviewBrief.version) && session.loopInterviewBrief.interviewId === currentInterview(session)) {
134
+ var edited = brief(Object.assign({}, session.loopInterviewBrief, msg.brief || {}));
135
+ if (edited) { edited.interviewId = session.loopInterviewBrief.interviewId; edited.version = session.loopInterviewBrief.version + 1; session.loopInterviewBrief = edited; sm.saveSessionFile(session); publish(session); }
136
+ }
137
+ return true;
138
+ }
139
+ var proposal = session.loopInterviewBrief;
140
+ if (!proposal || proposal.interviewId !== currentInterview(session) || text(msg.proposalId, 200) !== proposal.id || Number(msg.version) !== Number(proposal.version)) {
141
+ sendTo(ws, { type: "loop_interview_start_run_result", ok: false, sessionId: session.localId, requestId: text(msg.requestId, 200), error: "This Loop brief is stale or no longer pending." });
142
+ return true;
143
+ }
144
+ autonomousRun.startApprovedBrief(ws, session, proposal, text(msg.requestId, 200), function () {
145
+ return !!(session.loopInterviewBrief && session.loopInterviewBrief.id === proposal.id && Number(session.loopInterviewBrief.version) === Number(proposal.version) && session.loopInterviewBrief.interviewId === currentInterview(session));
146
+ }).then(function (ok) {
147
+ if (ok && session.loopInterviewBrief && session.loopInterviewBrief.id === proposal.id) {
148
+ var closeEntry = { type: "loop_interview_close", source: "loop_interview_close", interviewId: proposal.interviewId, proposalId: proposal.id, proposalVersion: proposal.version, _ts: Date.now() };
149
+ session.history.push(closeEntry);
150
+ sm.appendToSessionFile(session, closeEntry);
151
+ session.loopInterviewBrief = null;
152
+ sm.saveSessionFile(session);
153
+ publish(session);
154
+ }
155
+ }).catch(function (error) {
156
+ sendTo(ws, { type: "loop_interview_start_run_result", ok: false, sessionId: session.localId, requestId: text(msg.requestId, 200), error: error.message || String(error) });
157
+ });
158
+ return true;
159
+ }
160
+ return { getToolDefs: getToolDefs, handleMessage: handleMessage };
161
+ }
162
+
163
+ module.exports = { attachLoopInterview: attachLoopInterview, brief: brief, activeInterviewId: activeInterviewId };
@@ -3,6 +3,10 @@ var path = require("path");
3
3
  var crypto = require("crypto");
4
4
  var { execFileSync } = require("child_process");
5
5
  var { createLoopRegistry } = require("./scheduler");
6
+ var loopGuidance = require("./loop-guidance");
7
+ var validateCron = require("./schedule-validation").validateCron;
8
+ var validateScheduleRule = require("./schedule-rule").validateScheduleRule;
9
+ var attachLoopDurableSchedule = require("./loop-durable-schedule").attachLoopDurableSchedule;
6
10
 
7
11
  /**
8
12
  * Attach loop engine to a project context.
@@ -62,7 +66,7 @@ function attachLoop(ctx) {
62
66
  var _loopUtils = require("./utils");
63
67
  var _loopDir = path.join(_loopConfig.CONFIG_DIR, "loops");
64
68
  var _loopEncodedCwd = _loopUtils.resolveEncodedFile(_loopDir, cwd, ".json");
65
- var _loopStatePath = path.join(_loopDir, _loopEncodedCwd + ".json");
69
+ var _loopStatePath = ctx.loopStatePath || path.join(_loopDir, _loopEncodedCwd + ".json");
66
70
 
67
71
  function saveLoopState() {
68
72
  try {
@@ -269,18 +273,43 @@ function attachLoop(ctx) {
269
273
 
270
274
  // --- Loop Registry (unified one-off + scheduled) ---
271
275
  var activeRegistryId = null; // track which registry record triggered current loop
272
- var pendingTriggers = []; // queue for deferred triggers when skipIfRunning=false
276
+ var durableSchedule = null;
277
+ function isMultiUser() { return typeof ctx.isMultiUser === "function" ? ctx.isMultiUser() : !!ctx.isMultiUser; }
278
+
279
+ function legacyRuntimeFor(session) {
280
+ var record = loopRegistry.getById(loopState.loopId);
281
+ var linuxUser = getLinuxUserForSession(session);
282
+ if (!isMultiUser()) return { ok: true, linuxUser: linuxUser };
283
+ if (!record || !record.ownerId || session.ownerId !== record.ownerId) return { ok: false, error: "The scheduled Loop owner changed before execution." };
284
+ if (ctx.authorizeScheduledRun && !ctx.authorizeScheduledRun(record)) return { ok: false, error: "The scheduled Loop owner is no longer authorized for this project." };
285
+ if (ctx.requiresLinuxUser && ctx.requiresLinuxUser() && !linuxUser) return { ok: false, error: "The scheduled Loop owner no longer has a valid OS identity." };
286
+ return { ok: true, linuxUser: linuxUser };
287
+ }
288
+
289
+ function rejectLegacyRuntime(session, error) {
290
+ var message = error || "The scheduled Loop execution identity is no longer valid.";
291
+ var entry = { type: "error", text: message };
292
+ session.isProcessing = false; session.singleTurn = false; if (session.loop) session.loop.active = false;
293
+ session.history.push(entry); sm.appendToSessionFile(session, entry); sm.saveSessionFile(session);
294
+ sendToSession(session.localId, entry); sendToSession(session.localId, { type: "status", status: "idle" }); send({ type: "loop_error", text: message }); finishLoop("error");
295
+ }
273
296
 
274
297
  function triggerFromQueue(record) {
275
- // For schedule records, resolve the linked task to get loop files
298
+ if (record.createdViaScheduledTasks && ctx.authorizeScheduledRun && !ctx.authorizeScheduledRun(record)) {
299
+ console.error("[loop-registry] Scheduled task owner is no longer authorized: " + record.name);
300
+ return;
301
+ }
302
+ // Linked calendar schedules use their task's files. Standalone Scheduled Tasks own theirs.
276
303
  var loopFilesId = record.id;
277
304
  if (record.source === "schedule") {
278
- if (!record.linkedTaskId) {
279
- console.error("[loop-registry] Schedule has no linked task: " + record.name);
280
- return;
305
+ var standaloneScheduledTask = record.createdViaScheduledTasks && record.mode === "simple" && !record.linkedTaskId;
306
+ if (!record.linkedTaskId && !standaloneScheduledTask) {
307
+ failScheduledDispatch("Schedule has no linked task: " + record.name);
308
+ }
309
+ if (record.linkedTaskId) {
310
+ loopFilesId = record.linkedTaskId;
311
+ console.log("[loop-registry] Schedule triggered: " + record.name + " -> linked task " + loopFilesId);
281
312
  }
282
- loopFilesId = record.linkedTaskId;
283
- console.log("[loop-registry] Schedule triggered: " + record.name + " -> linked task " + loopFilesId);
284
313
  }
285
314
 
286
315
  // Verify the loop directory and PROMPT.md exist
@@ -288,8 +317,7 @@ function attachLoop(ctx) {
288
317
  try {
289
318
  fs.accessSync(path.join(recDir, "PROMPT.md"));
290
319
  } catch (e) {
291
- console.error("[loop-registry] PROMPT.md missing for " + loopFilesId);
292
- return;
320
+ failScheduledDispatch("PROMPT.md is missing for " + loopFilesId + ".");
293
321
  }
294
322
  // Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
295
323
  loopState.loopId = record.id;
@@ -304,28 +332,65 @@ function attachLoop(ctx) {
304
332
  startLoop({ maxIterations: record.maxIterations, name: record.name });
305
333
  }
306
334
 
335
+ function failScheduledDispatch(message) {
336
+ console.error("[loop-registry] " + message);
337
+ send({ type: "loop_error", text: message });
338
+ var error = new Error(message); error.code = "SCHEDULE_DISPATCH_FAILED"; throw error;
339
+ }
340
+
341
+ function usesPairExecution(record) { return !!(record && (record.createdViaScheduledTasks === true || record.source === "schedule")); }
342
+
307
343
  var loopRegistry = createLoopRegistry({
308
344
  cwd: cwd,
309
- onTrigger: function (record) {
310
- // Skip or queue trigger if a loop is already active
345
+ registryPath: ctx.loopRegistryPath,
346
+ canTrigger: function (record) {
347
+ if (!usesPairExecution(record)) return true;
348
+ return ctx.canExecuteScheduledTask ? ctx.canExecuteScheduledTask(record) : false;
349
+ },
350
+ prepareTrigger: function (record) {
351
+ if (!usesPairExecution(record)) return null;
352
+ return ctx.prepareScheduledTask ? ctx.prepareScheduledTask(record, "schedule") : { ok: false };
353
+ },
354
+ onTrigger: function (record, prepared) {
355
+ if (usesPairExecution(record)) {
356
+ if (prepared && (prepared.skipped || prepared.queued)) return;
357
+ var scheduledResult = ctx.executeScheduledTask && ctx.executeScheduledTask(record, "schedule", prepared);
358
+ if (!scheduledResult || !scheduledResult.ok) failScheduledDispatch("Pair execution did not start: " + (scheduledResult && scheduledResult.error || record.name));
359
+ return;
360
+ }
361
+ // A busy non-skipping occurrence remains queued in the durable scheduler.
311
362
  if (loopState.active || loopState.phase === "executing") {
312
363
  if (record.skipIfRunning !== false) {
313
364
  console.log("[loop-registry] Skipping trigger for " + record.name + " — loop already active (skipIfRunning)");
314
365
  return;
315
366
  }
316
- console.log("[loop-registry] Loop active, queuing trigger for " + record.name);
317
- pendingTriggers.push(record);
318
367
  return;
319
368
  }
320
369
 
321
370
  triggerFromQueue(record);
322
371
  },
323
372
  onChange: function () {
373
+ if (durableSchedule) durableSchedule.sync();
324
374
  send({ type: "loop_registry_updated", records: getHubSchedules() });
325
375
  },
326
376
  });
327
377
  loopRegistry.load();
328
- loopRegistry.startTimer();
378
+ if (ctx.durableScheduler) {
379
+ durableSchedule = attachLoopDurableSchedule({
380
+ scheduler: ctx.durableScheduler,
381
+ registry: loopRegistry,
382
+ projectId: slug,
383
+ isMultiUser: ctx.isMultiUser,
384
+ authorize: function (record) {
385
+ if (!isMultiUser() && !usesPairExecution(record)) return true;
386
+ return !ctx.authorizeScheduledRun || ctx.authorizeScheduledRun(record);
387
+ },
388
+ canRun: function (record) {
389
+ if (usesPairExecution(record)) return ctx.canExecuteScheduledTask ? ctx.canExecuteScheduledTask(record) : false;
390
+ return !(record.skipIfRunning === false && (loopState.active || loopState.phase === "executing"));
391
+ },
392
+ });
393
+ } else loopRegistry.startTimer();
329
394
 
330
395
  // Wire loop info resolution for session list broadcasts
331
396
  sm.setResolveLoopInfo(function (loopId) {
@@ -412,8 +477,8 @@ function attachLoop(ctx) {
412
477
  return;
413
478
  }
414
479
 
415
- var session = sm.createSession();
416
480
  var loopSource = loopRegistry.getById(loopState.loopId);
481
+ var session = sm.createSession({ ownerId: loopSource && loopSource.ownerId || null });
417
482
  var loopName = (loopState.wizardData && loopState.wizardData.name) || (loopSource && loopSource.name) || "";
418
483
  var loopSourceTag = (loopSource && loopSource.source) || null;
419
484
  var isRalphLoop = loopSourceTag === "ralph";
@@ -430,6 +495,8 @@ function attachLoop(ctx) {
430
495
  maxIterations: loopState.maxIterations,
431
496
  sessionId: session.localId,
432
497
  });
498
+ var coderRuntime = legacyRuntimeFor(session);
499
+ if (!coderRuntime.ok) { rejectLegacyRuntime(session, coderRuntime.error); return; }
433
500
 
434
501
  var coderCompleted = false;
435
502
  session.onQueryComplete = function(completedSession) {
@@ -508,7 +575,7 @@ function attachLoop(ctx) {
508
575
  session.acceptEditsAfterStart = true;
509
576
  session.singleTurn = true;
510
577
  if (loopState.settings) session.loopSettings = loopState.settings;
511
- sdk.startQuery(session, loopState.promptText, undefined, getLinuxUserForSession(session));
578
+ sdk.startQuery(session, loopState.promptText, undefined, coderRuntime.linuxUser);
512
579
  }
513
580
 
514
581
  function runJudge() {
@@ -549,8 +616,8 @@ function attachLoop(ctx) {
549
616
  "- PASS: [brief explanation]\n" +
550
617
  "- FAIL: [brief explanation of what is still missing]";
551
618
 
552
- var judgeSession = sm.createSession();
553
619
  var judgeSource = loopRegistry.getById(loopState.loopId);
620
+ var judgeSession = sm.createSession({ ownerId: judgeSource && judgeSource.ownerId || null });
554
621
  var judgeName = (loopState.wizardData && loopState.wizardData.name) || (judgeSource && judgeSource.name) || "";
555
622
  var judgeSourceTag = (judgeSource && judgeSource.source) || null;
556
623
  var isRalphJudge = judgeSourceTag === "ralph";
@@ -565,6 +632,8 @@ function attachLoop(ctx) {
565
632
  iteration: loopState.iteration,
566
633
  sessionId: judgeSession.localId,
567
634
  });
635
+ var judgeRuntime = legacyRuntimeFor(judgeSession);
636
+ if (!judgeRuntime.ok) { rejectLegacyRuntime(judgeSession, judgeRuntime.error); return; }
568
637
 
569
638
  var judgeCompleted = false;
570
639
  judgeSession.onQueryComplete = function(completedSession) {
@@ -625,7 +694,7 @@ function attachLoop(ctx) {
625
694
  judgeSession.acceptEditsAfterStart = true;
626
695
  judgeSession.singleTurn = true;
627
696
  if (loopState.settings) judgeSession.loopSettings = loopState.settings;
628
- sdk.startQuery(judgeSession, judgePrompt, undefined, getLinuxUserForSession(judgeSession));
697
+ sdk.startQuery(judgeSession, judgePrompt, undefined, judgeRuntime.linuxUser);
629
698
  }
630
699
 
631
700
  function parseJudgeVerdict(session) {
@@ -710,14 +779,7 @@ function attachLoop(ctx) {
710
779
  });
711
780
  }
712
781
 
713
- // Process next queued trigger if any
714
- if (pendingTriggers.length > 0) {
715
- var next = pendingTriggers.shift();
716
- console.log("[loop-registry] Processing queued trigger: " + next.name);
717
- setTimeout(function () {
718
- triggerFromQueue(next);
719
- }, 1000);
720
- }
782
+ if (durableSchedule) durableSchedule.wake();
721
783
  }
722
784
 
723
785
  function resumeLoop() {
@@ -891,13 +953,7 @@ function attachLoop(ctx) {
891
953
  loopState.phase = "crafting";
892
954
  saveLoopState();
893
955
 
894
- var judgeCraftPrompt = "Use the /clay-ralph skill to design ONLY a JUDGE.md for an existing Ralph Loop. " +
895
- "The user has already provided PROMPT.md, so do NOT create or modify PROMPT.md. " +
896
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
897
- "Your job is to read the existing PROMPT.md and create a JUDGE.md " +
898
- "that will evaluate whether the coder session completed the task successfully.\n\n" +
899
- "## Task\n" + (wData.task || "") +
900
- "\n\n## Loop Directory\n" + lDir;
956
+ var judgeCraftPrompt = loopGuidance.legacyCraftPrompt({ judgeOnly: true, task: wData.task, directory: lDir });
901
957
 
902
958
  var judgeCraftSession = sm.createSession();
903
959
  judgeCraftSession.title = (isRalphCraft ? "Ralph" : "Task") + (craftName ? " " + craftName : "") + " Crafting";
@@ -934,31 +990,9 @@ function attachLoop(ctx) {
934
990
  return true;
935
991
  }
936
992
 
937
- // Default: "draft" mode — Clay crafts files via the clay-ralph skill
993
+ // Default: "draft" mode — Clay crafts legacy PROMPT.md/JUDGE.md files
938
994
  var _draftIsSimple = wData.loopMode === "simple";
939
- var craftingPrompt;
940
- if (_draftIsSimple) {
941
- craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
942
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
943
- "This is a Simple Loop (no judge). Your job is to create ONLY a PROMPT.md file " +
944
- "that a future autonomous session will execute. Do NOT create a JUDGE.md file.\n\n" +
945
- "## Task\n" + (wData.task || "") +
946
- "\n\n## Loop Directory\n" + lDir;
947
- } else if (wData.judgeAuthor === "me") {
948
- craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
949
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
950
- "The user will provide their own JUDGE.md, so create ONLY a PROMPT.md file " +
951
- "that a future autonomous session will execute. Do NOT create a JUDGE.md file.\n\n" +
952
- "## Task\n" + (wData.task || "") +
953
- "\n\n## Loop Directory\n" + lDir;
954
- } else {
955
- craftingPrompt = "Use the /clay-ralph skill to design a Ralph Loop for the following task. " +
956
- "You MUST invoke the clay-ralph skill — do NOT execute the task yourself. " +
957
- "Your job is to interview me, then create PROMPT.md and JUDGE.md files " +
958
- "that a future autonomous session will execute.\n\n" +
959
- "## Task\n" + (wData.task || "") +
960
- "\n\n## Loop Directory\n" + lDir;
961
- }
995
+ var craftingPrompt = loopGuidance.legacyCraftPrompt({ simple: _draftIsSimple, userJudge: wData.judgeAuthor === "me", task: wData.task, directory: lDir });
962
996
 
963
997
  // Pre-write user-provided files before crafting starts
964
998
  if (wData.judgeText && wData.judgeAuthor === "me") {
@@ -1106,6 +1140,27 @@ function attachLoop(ctx) {
1106
1140
  // --- Schedule create (from calendar click) ---
1107
1141
  if (msg.type === "schedule_create") {
1108
1142
  var sData = msg.data || {};
1143
+ var scheduleActor = ctx.getScheduleActor ? ctx.getScheduleActor(ws) : { ownerId: null, ownerName: null, sessionId: null };
1144
+ if (!scheduleActor) {
1145
+ sendTo(ws, { type: "loop_registry_error", text: "The schedule owner could not be verified." });
1146
+ return true;
1147
+ }
1148
+ if (sData.cron) {
1149
+ var scheduleCreateCron = validateCron(sData.cron);
1150
+ if (!scheduleCreateCron.ok) {
1151
+ sendTo(ws, { type: "loop_registry_error", text: scheduleCreateCron.error });
1152
+ return true;
1153
+ }
1154
+ sData.cron = scheduleCreateCron.cron;
1155
+ }
1156
+ if (sData.scheduleRule) {
1157
+ var scheduleCreateRule = validateScheduleRule(sData.scheduleRule);
1158
+ if (!scheduleCreateRule.ok) {
1159
+ sendTo(ws, { type: "loop_registry_error", text: scheduleCreateRule.error });
1160
+ return true;
1161
+ }
1162
+ sData.scheduleRule = scheduleCreateRule.value;
1163
+ }
1109
1164
  loopRegistry.register({
1110
1165
  name: sData.name || "Untitled",
1111
1166
  task: sData.name || "",
@@ -1122,6 +1177,11 @@ function attachLoop(ctx) {
1122
1177
  recurrenceEnd: sData.recurrenceEnd || null,
1123
1178
  skipIfRunning: sData.skipIfRunning !== undefined ? sData.skipIfRunning : true,
1124
1179
  intervalEnd: sData.intervalEnd || null,
1180
+ scheduleRule: sData.scheduleRule || null,
1181
+ ownerId: scheduleActor.ownerId,
1182
+ ownerName: scheduleActor.ownerName,
1183
+ sessionId: scheduleActor.sessionId,
1184
+ createdViaScheduledTasks: true,
1125
1185
  });
1126
1186
  return true;
1127
1187
  }
@@ -1139,7 +1199,28 @@ function attachLoop(ctx) {
1139
1199
  }
1140
1200
 
1141
1201
  if (msg.type === "loop_registry_update") {
1142
- var updatedRec = loopRegistry.update(msg.id, msg.data || {});
1202
+ if (msg.data && msg.data.cron !== undefined && msg.data.cron !== null) {
1203
+ var scheduleUpdateCron = validateCron(msg.data.cron);
1204
+ if (!scheduleUpdateCron.ok) {
1205
+ sendTo(ws, { type: "loop_registry_error", text: scheduleUpdateCron.error });
1206
+ return true;
1207
+ }
1208
+ msg.data.cron = scheduleUpdateCron.cron;
1209
+ }
1210
+ if (msg.data && msg.data.scheduleRule) {
1211
+ var scheduleUpdateRule = validateScheduleRule(msg.data.scheduleRule);
1212
+ if (!scheduleUpdateRule.ok) {
1213
+ sendTo(ws, { type: "loop_registry_error", text: scheduleUpdateRule.error });
1214
+ return true;
1215
+ }
1216
+ msg.data.scheduleRule = scheduleUpdateRule.value;
1217
+ }
1218
+ var registryUpdateData = Object.assign({}, msg.data || {});
1219
+ delete registryUpdateData.ownerId;
1220
+ delete registryUpdateData.ownerName;
1221
+ delete registryUpdateData.sessionId;
1222
+ delete registryUpdateData.createdViaScheduledTasks;
1223
+ var updatedRec = loopRegistry.update(msg.id, registryUpdateData);
1143
1224
  if (!updatedRec) {
1144
1225
  sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
1145
1226
  }
@@ -1197,15 +1278,20 @@ function attachLoop(ctx) {
1197
1278
 
1198
1279
  if (msg.type === "loop_registry_rerun") {
1199
1280
  // Re-run an existing job (one-off from library)
1200
- if (loopState.active || loopState.phase === "executing") {
1201
- sendTo(ws, { type: "loop_registry_error", text: "A loop is already running" });
1202
- return true;
1203
- }
1204
1281
  var rerunRec = loopRegistry.getById(msg.id);
1205
1282
  if (!rerunRec) {
1206
1283
  sendTo(ws, { type: "loop_registry_error", text: "Record not found" });
1207
1284
  return true;
1208
1285
  }
1286
+ if (usesPairExecution(rerunRec)) {
1287
+ var pairRun = ctx.executeScheduledTask && ctx.executeScheduledTask(rerunRec, "manual");
1288
+ if (!pairRun || !pairRun.ok) sendTo(ws, { type: "loop_registry_error", text: pairRun && pairRun.error || "The scheduled pair could not start." });
1289
+ return true;
1290
+ }
1291
+ if (loopState.active || loopState.phase === "executing") {
1292
+ sendTo(ws, { type: "loop_registry_error", text: "A loop is already running" });
1293
+ return true;
1294
+ }
1209
1295
  var rerunDir = path.join(cwd, ".claude", "loops", rerunRec.id);
1210
1296
  try {
1211
1297
  fs.accessSync(path.join(rerunDir, "PROMPT.md"));
@@ -1297,7 +1383,7 @@ function attachLoop(ctx) {
1297
1383
  getSchedules: function () { return loopRegistry.getAll(); },
1298
1384
  importSchedule: function (data) { return loopRegistry.register(data); },
1299
1385
  removeSchedule: function (id) { return loopRegistry.remove(id); },
1300
- stopTimer: function () { loopRegistry.stopTimer(); },
1386
+ stopTimer: function () { if (durableSchedule) durableSchedule.shutdown(); else loopRegistry.stopTimer(); },
1301
1387
  };
1302
1388
  }
1303
1389
 
@@ -0,0 +1,16 @@
1
+ function stopAutonomousWork(session, roles, turnControl, taskControl, workerPermission) {
2
+ if (!roles) return false;
3
+ var runId = session.autonomousRun && session.autonomousRun.id;
4
+ var cancelled = taskControl.cancelAll(roles.worker, runId);
5
+ var owned = roles.worker._pairDelegation && roles.worker._pairDelegation.autonomousRunId === runId;
6
+ if (!owned && !cancelled) return true;
7
+ turnControl.markHumanStop(session);
8
+ if (!owned) return true;
9
+ taskControl.markInterruption(roles.worker, "user", "The Until complete run was stopped.");
10
+ workerPermission.cancelForSession(roles.worker, "The Until complete run was stopped.");
11
+ roles.worker.taskStopRequested = true;
12
+ if (roles.worker.abortController) roles.worker.abortController.abort();
13
+ return true;
14
+ }
15
+
16
+ module.exports = { stopAutonomousWork: stopAutonomousWork };