shraga 0.0.3 → 0.1.2

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 (180) hide show
  1. package/README.md +82 -27
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +59 -14
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. package/src/server/workspace.ts +249 -0
@@ -0,0 +1,334 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { dirname, resolve, isAbsolute } from 'node:path';
4
+ import { DATA_DIR } from '../paths.ts';
5
+ import { streamChat, type PermissionHandler } from '../claude.ts';
6
+ import { getMcpConfig } from '../mcp.ts';
7
+ import { appendMessage, createScheduledSession, updateScheduledSessionStatus, setRunStatus, registerLivePartial, unregisterLivePartial, writePartial, clearPartial, acquireSessionLock, releaseSessionLock, type ConvBlock } from '../sessions.ts';
8
+ import type { Schedule, ScheduleRunSummary } from './types.ts';
9
+ import { writeRunningMarker, clearRunningMarker } from './storage.ts';
10
+ import { addUnread } from '../unread.ts';
11
+
12
+ export interface RunContext {
13
+ sessionId: string;
14
+ abortController: AbortController;
15
+ }
16
+
17
+ function emitAbortMessage(sessionId: string, onEvent: (ev: object) => void): void {
18
+ appendMessage(sessionId, {
19
+ id: crypto.randomUUID(),
20
+ role: 'assistant',
21
+ blocks: [{ type: 'text', text: '⛔ Run aborted by user.' }],
22
+ });
23
+ onEvent({ type: 'session_messages_changed', sessionId });
24
+ }
25
+
26
+ /**
27
+ * Execute one fire of a schedule. Creates a real session, streams the agent,
28
+ * persists conversation blocks to JSONL, returns a run summary.
29
+ *
30
+ * For bash tasks: synthesizes an agent prompt and auto-approves ONLY the exact
31
+ * Bash invocation matching `task.command`. Other tools fall through to deny —
32
+ * the model can't silently expand scope.
33
+ */
34
+ export interface ResumeOptions {
35
+ /** Existing scheduler session to resume in-place (no new session is created). */
36
+ sessionId: string;
37
+ /** Prompt to feed the agent to continue (e.g. "continue from where you left off"). */
38
+ prompt: string;
39
+ }
40
+
41
+ /** The event that fired an `event`-trigger schedule. Injected into the run so the
42
+ * agent (prompt task) or the spawned process (job task) can see what happened. */
43
+ export interface EventContext {
44
+ source: string;
45
+ payload: unknown;
46
+ }
47
+
48
+ /** Render an event as a prompt block (prompt tasks). Payload is capped so a large
49
+ * webhook body can't blow the context window. */
50
+ /**
51
+ * Resolve a schedule's `promptFile` independently of process.cwd().
52
+ *
53
+ * Reading it CWD-relative only worked when the server happened to be launched from the app root;
54
+ * a deployment with a different WorkingDirectory made every relative promptFile throw ENOENT and
55
+ * silently kill the run. Anchors, in order:
56
+ * 1. dirname(DATA_DIR) — the app root. Values written by this app are app-root-relative and
57
+ * ALREADY include the data dir (e.g. "data/workspace/x.md"). Same convention as job cwd.
58
+ * 2. DATA_DIR — the intuitive data-dir-relative form (e.g. "workspace/x.md").
59
+ * Absolute paths pass through. If neither exists, return the app-root path so the ENOENT names it.
60
+ */
61
+ export function resolvePromptFile(p: string): string {
62
+ if (isAbsolute(p)) return p;
63
+ const rootAnchored = resolve(dirname(DATA_DIR), p);
64
+ if (existsSync(rootAnchored)) return rootAnchored;
65
+ const dataAnchored = resolve(DATA_DIR, p);
66
+ return existsSync(dataAnchored) ? dataAnchored : rootAnchored;
67
+ }
68
+
69
+ function formatEventBlock(e: EventContext): string {
70
+ let body: string;
71
+ try { body = JSON.stringify(e.payload, null, 2); } catch { body = String(e.payload); }
72
+ if (body.length > 8000) body = `${body.slice(0, 8000)}\n…(truncated)`;
73
+ return `An external event triggered this automation.\nSource: ${e.source}\nEvent data:\n\`\`\`json\n${body}\n\`\`\``;
74
+ }
75
+
76
+ export async function runSchedule(
77
+ schedule: Schedule,
78
+ onEvent: (ev: object) => void,
79
+ registerRun: (sid: string, ac: AbortController) => void,
80
+ override?: string,
81
+ resume?: ResumeOptions,
82
+ eventCtx?: EventContext,
83
+ ): Promise<ScheduleRunSummary> {
84
+ const now = Date.now();
85
+ // Resume reuses the interrupted run's session (same convo) instead of spawning a new one.
86
+ const sessionId = resume ? resume.sessionId : `sched-${schedule.id}-${now}`;
87
+ const abortController = new AbortController();
88
+ registerRun(sessionId, abortController);
89
+
90
+ const title = `⏰ ${schedule.name}`;
91
+ // On resume the session already exists in the store — don't recreate it.
92
+ if (!resume) createScheduledSession(sessionId, schedule.id, title, schedule.createdBy, schedule.scope);
93
+ acquireSessionLock(sessionId, 'scheduler', abortController);
94
+ setRunStatus(sessionId, 'running', 'scheduler');
95
+ onEvent({ type: 'session_busy', sessionId, busy: true });
96
+ // Mark this period as running with the live pid so startup catch-up won't double-fire it.
97
+ writeRunningMarker({ pid: process.pid, startedAt: now, scheduleId: schedule.id });
98
+
99
+ const task = schedule.task;
100
+ if (task.kind === 'job') {
101
+ return await runJobSchedule(schedule, sessionId, abortController, onEvent, eventCtx).finally(() => {
102
+ if (releaseSessionLock(sessionId, abortController)) {
103
+ setRunStatus(sessionId, 'idle');
104
+ onEvent({ type: 'session_busy', sessionId, busy: false });
105
+ }
106
+ });
107
+ }
108
+
109
+ let prompt: string;
110
+ if (resume) {
111
+ // The original task prompt is already in the conversation from the interrupted run.
112
+ prompt = resume.prompt;
113
+ } else if (task.kind === 'bash') {
114
+ const cmd = override || task.command;
115
+ prompt = `Run exactly this bash command and report the result concisely:\n\n\`\`\`bash\n${cmd}\n\`\`\``;
116
+ } else {
117
+ let base = task.promptFile ? readFileSync(resolvePromptFile(task.promptFile), 'utf-8').trim() : (task.prompt ?? '');
118
+ if (override) base = `${base}\n\n---\nAdditional instructions for this run:\n${override}`;
119
+ if (eventCtx) base = `${base}\n\n---\n${formatEventBlock(eventCtx)}`;
120
+ prompt = base;
121
+ }
122
+
123
+ // Save the synthesized user prompt to the conversation (skip on resume — task prompt already persisted).
124
+ if (!resume) {
125
+ appendMessage(sessionId, {
126
+ id: crypto.randomUUID(),
127
+ role: 'user',
128
+ blocks: [{ type: 'text', text: prompt }],
129
+ channel: 'scheduler',
130
+ });
131
+ }
132
+
133
+ // Scoped permission handler for bash runs
134
+ const allowedCmd = task.kind === 'bash' ? (override || task.command) : null;
135
+ const onPermissionRequest: PermissionHandler | undefined = task.kind === 'bash'
136
+ ? async (_id, tool, input) => {
137
+ if (tool === 'Bash' && typeof (input as any).command === 'string' && (input as any).command === allowedCmd) {
138
+ return { allow: true };
139
+ }
140
+ return { allow: false };
141
+ }
142
+ : async () => ({ allow: true }); // prompt tasks: fully trusted scheduled run
143
+
144
+ const mcpServers = getMcpConfig(schedule.createdBy.uid);
145
+
146
+ let assistantText = '';
147
+ const assistantBlocks: ConvBlock[] = [];
148
+ const collectPartialBlocks = () => [
149
+ ...assistantBlocks,
150
+ ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : []),
151
+ ];
152
+ registerLivePartial(sessionId, collectPartialBlocks);
153
+ const partialInterval = setInterval(() => {
154
+ const blocks = collectPartialBlocks();
155
+ if (blocks.length) writePartial(sessionId, blocks);
156
+ }, 5_000);
157
+ const flush = () => {
158
+ clearInterval(partialInterval);
159
+ unregisterLivePartial(sessionId);
160
+ clearPartial(sessionId);
161
+ if (assistantText) assistantBlocks.push({ type: 'text', text: assistantText });
162
+ if (assistantBlocks.length === 0) return;
163
+ appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks: assistantBlocks });
164
+ };
165
+
166
+ let status: ScheduleRunSummary['status'] = 'ok';
167
+ let error: string | undefined;
168
+
169
+ onEvent({ type: 'schedule:run_started', scheduleId: schedule.id, sessionId, at: now });
170
+
171
+ try {
172
+ for await (const ev of streamChat({
173
+ prompt,
174
+ sessionId,
175
+ uid: schedule.createdBy.uid,
176
+ userEmail: schedule.createdBy.email,
177
+ userName: schedule.createdBy.email.split('@')[0],
178
+ mcpServers,
179
+ abortController,
180
+ onPermissionRequest,
181
+ })) {
182
+ onEvent({ type: 'session_stream', sessionId, event: ev });
183
+ if (ev.type === 'text_delta') {
184
+ assistantText += ev.text;
185
+ } else if (ev.type === 'tool_use') {
186
+ if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
187
+ assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
188
+ } else if (ev.type === 'tool_result') {
189
+ assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
190
+ } else if (ev.type === 'done') {
191
+ break;
192
+ } else if (ev.type === 'error') {
193
+ status = abortController.signal.aborted ? 'aborted' : 'error';
194
+ error = ev.message;
195
+ break;
196
+ }
197
+ }
198
+ } catch (err: any) {
199
+ if (abortController.signal.aborted) {
200
+ status = 'aborted';
201
+ } else {
202
+ status = 'error';
203
+ error = err?.message ?? String(err);
204
+ console.error(`[scheduler] run error for ${schedule.id}:`, error);
205
+ }
206
+ } finally {
207
+ flush();
208
+ // Run reached a terminal state in-process — drop the running marker (a crash leaves it
209
+ // behind on purpose; catch-up's isProcessAlive guard then handles the dead pid).
210
+ clearRunningMarker(schedule.id);
211
+ if (status === 'aborted') emitAbortMessage(sessionId, onEvent);
212
+ if (releaseSessionLock(sessionId, abortController)) {
213
+ setRunStatus(sessionId, 'idle');
214
+ onEvent({ type: 'session_busy', sessionId, busy: false });
215
+ }
216
+ updateScheduledSessionStatus(sessionId, status);
217
+ }
218
+
219
+ const preview = assistantText.slice(0, 120) || (status === 'ok' ? 'Schedule completed' : `Schedule ${status}`);
220
+ addUnread(schedule.createdBy.uid, sessionId, preview, 'schedule', schedule.name);
221
+
222
+ const summary: ScheduleRunSummary = { at: now, sessionId, status, error };
223
+ onEvent({ type: 'schedule:run_finished', scheduleId: schedule.id, sessionId, summary });
224
+ return summary;
225
+ }
226
+
227
+ async function runJobSchedule(
228
+ schedule: Schedule,
229
+ sessionId: string,
230
+ abortController: AbortController,
231
+ onEvent: (ev: object) => void,
232
+ eventCtx?: EventContext,
233
+ ): Promise<ScheduleRunSummary> {
234
+ const now = Date.now();
235
+ const task = schedule.task;
236
+ if (task.kind !== 'job') throw new Error(`Expected job task for schedule ${schedule.id}`);
237
+ const command = task.command;
238
+ appendMessage(sessionId, {
239
+ id: crypto.randomUUID(),
240
+ role: 'user',
241
+ blocks: [{ type: 'text', text: `Run: \`${task.command}\`` }],
242
+ });
243
+ onEvent({ type: 'schedule:run_started', scheduleId: schedule.id, sessionId, at: now });
244
+
245
+ let output = '';
246
+ const prefix = `\`$ ${command}\`\n\n\`\`\`\n`;
247
+ const collectPartial = () => [{ type: 'text' as const, text: `⏳ Running…\n\n${prefix}${output}\`\`\`` }];
248
+ registerLivePartial(sessionId, collectPartial);
249
+ const partialInterval = setInterval(() => {
250
+ if (output) {
251
+ writePartial(sessionId, collectPartial());
252
+ onEvent({ type: 'session_messages_changed', sessionId });
253
+ }
254
+ }, 2_000);
255
+
256
+ let status: ScheduleRunSummary['status'] = 'ok';
257
+ let error: string | undefined;
258
+ // Event-fired jobs see the payload as SHRAGA_EVENT (JSON) — isolated to this spawn, never the
259
+ // command string. Also written as the legacy UNCLAW_EVENT for jobs already reading that name.
260
+ const extraEnv = eventCtx
261
+ ? { SHRAGA_EVENT: JSON.stringify(eventCtx), UNCLAW_EVENT: JSON.stringify(eventCtx) }
262
+ : undefined;
263
+ try {
264
+ await runCommandWithMarker(command, abortController, schedule.id, (chunk) => { output += chunk; }, extraEnv);
265
+ appendMessage(sessionId, {
266
+ id: crypto.randomUUID(),
267
+ role: 'assistant',
268
+ blocks: [{ type: 'text', text: `✅ Job completed successfully.\n\n${prefix}${output.trim() || '(no output)'}\n\`\`\`` }],
269
+ });
270
+ } catch (err: any) {
271
+ status = abortController.signal.aborted ? 'aborted' : 'error';
272
+ error = err?.message ?? String(err);
273
+ appendMessage(sessionId, {
274
+ id: crypto.randomUUID(),
275
+ role: 'assistant',
276
+ blocks: [{ type: 'text', text: `❌ Job failed.\n\n${prefix}${error}\n\`\`\`` }],
277
+ });
278
+ console.error(`[scheduler] job run error for ${schedule.id}:`, error);
279
+ } finally {
280
+ clearInterval(partialInterval);
281
+ unregisterLivePartial(sessionId);
282
+ clearPartial(sessionId);
283
+ clearRunningMarker(schedule.id);
284
+ if (status === 'aborted') emitAbortMessage(sessionId, onEvent);
285
+ updateScheduledSessionStatus(sessionId, status);
286
+ onEvent({ type: 'session_messages_changed', sessionId });
287
+ }
288
+
289
+ const jobPreview = status === 'ok' ? 'Job completed successfully' : `Job ${status}`;
290
+ addUnread(schedule.createdBy.uid, sessionId, jobPreview, 'schedule', schedule.name);
291
+
292
+ const summary: ScheduleRunSummary = { at: now, sessionId, status, error };
293
+ onEvent({ type: 'schedule:run_finished', scheduleId: schedule.id, sessionId, summary });
294
+ return summary;
295
+ }
296
+
297
+
298
+ function runCommandWithMarker(command: string, abortController: AbortController, scheduleId: string, onChunk?: (chunk: string) => void, extraEnv?: Record<string, string>): Promise<string> {
299
+ return new Promise((resolve, reject) => {
300
+ const child = spawn(command, {
301
+ // Anchor job cwd to the dir CONTAINING the data dir, not the server's launch dir. Builtin +
302
+ // user job commands are cwd-relative (`bun run data/scripts/X.ts`), so they must resolve
303
+ // against DATA_DIR regardless of where/how the server was started. `process.cwd()` only worked
304
+ // by coincidence when the process was launched from dirname(DATA_DIR); a deploy that sets a
305
+ // different WorkingDirectory (e.g. running the app against a data dir at a different mount, like /some/data-dir) then
306
+ // silently broke every data/ path. dirname(DATA_DIR) makes it launch-dir-independent.
307
+ cwd: dirname(DATA_DIR),
308
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
309
+ shell: true,
310
+ stdio: ['ignore', 'pipe', 'pipe'],
311
+ });
312
+
313
+ if (child.pid) {
314
+ writeRunningMarker({ pid: child.pid, startedAt: Date.now(), scheduleId });
315
+ }
316
+
317
+ let output = '';
318
+ const handleData = (chunk: Buffer) => {
319
+ const text = chunk.toString();
320
+ output += text;
321
+ onChunk?.(text);
322
+ };
323
+ child.stdout?.on('data', handleData);
324
+ child.stderr?.on('data', handleData);
325
+
326
+ abortController.signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
327
+ child.on('error', reject);
328
+ child.on('close', (code) => {
329
+ const trimmed = output.trim();
330
+ if (code === 0) resolve(trimmed);
331
+ else reject(new Error(trimmed || `Command failed with exit code ${code}`));
332
+ });
333
+ });
334
+ }
@@ -0,0 +1,98 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
2
+ import { DATA_DIR, dataPath } from '../paths.ts';
3
+ import type { Schedule, CompletionMarker, RunningMarker } from './types.ts';
4
+ import { dataSync } from '../data-sync.ts';
5
+
6
+ const FILE = dataPath('schedules.json');
7
+ const COMPLETIONS_DIR = dataPath('scheduler/completions');
8
+ const RUNNING_DIR = dataPath('scheduler/running');
9
+ const THROTTLE_FILE = dataPath('state/trigger-throttle.json');
10
+
11
+ /** Per-trigger event throttle ledger: dedup-key → last-fired epoch ms. */
12
+ export function loadThrottleState(): Record<string, number> {
13
+ if (!existsSync(THROTTLE_FILE)) return {};
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(THROTTLE_FILE, 'utf-8'));
16
+ return parsed && typeof parsed === 'object' ? parsed : {};
17
+ } catch (err) {
18
+ console.error('[scheduler] failed to parse trigger-throttle.json, starting fresh:', err);
19
+ return {};
20
+ }
21
+ }
22
+
23
+ export function saveThrottleState(state: Record<string, number>): void {
24
+ mkdirSync(dataPath('state'), { recursive: true });
25
+ const tmp = `${THROTTLE_FILE}.tmp`;
26
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
27
+ renameSync(tmp, THROTTLE_FILE);
28
+ }
29
+
30
+ export function loadSchedules(): Schedule[] {
31
+ if (!existsSync(FILE)) return [];
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(FILE, 'utf-8'));
34
+ return Array.isArray(parsed) ? parsed : [];
35
+ } catch (err) {
36
+ console.error('[scheduler] failed to parse schedules.json:', err);
37
+ return [];
38
+ }
39
+ }
40
+
41
+ export function saveSchedules(schedules: Schedule[]): void {
42
+ mkdirSync(DATA_DIR, { recursive: true });
43
+ // Guard: if existing file has significantly more schedules, back up before overwriting
44
+ if (existsSync(FILE)) {
45
+ try {
46
+ const existing = JSON.parse(readFileSync(FILE, 'utf-8'));
47
+ if (Array.isArray(existing) && existing.length > schedules.length + 2) {
48
+ const bak = `${FILE}.bak`;
49
+ writeFileSync(bak, JSON.stringify(existing, null, 2));
50
+ console.warn(`[scheduler] ⚠️ saving ${schedules.length} schedules over ${existing.length} on disk — backup at schedules.json.bak`);
51
+ }
52
+ } catch { /* parse error — overwrite is fine */ }
53
+ }
54
+ const tmp = `${FILE}.tmp`;
55
+ writeFileSync(tmp, JSON.stringify(schedules, null, 2));
56
+ renameSync(tmp, FILE);
57
+ dataSync.trackWrite('schedules.json');
58
+ }
59
+
60
+ export function readCompletionMarker(scheduleId: string): CompletionMarker | null {
61
+ const file = `${COMPLETIONS_DIR}/${scheduleId}.json`;
62
+ if (!existsSync(file)) return null;
63
+ try {
64
+ return JSON.parse(readFileSync(file, 'utf-8'));
65
+ } catch (err) {
66
+ console.error(`[scheduler] failed to read completion marker for ${scheduleId}:`, err);
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function writeCompletionMarker(marker: CompletionMarker): void {
72
+ mkdirSync(COMPLETIONS_DIR, { recursive: true });
73
+ const file = `${COMPLETIONS_DIR}/${marker.scheduleId}.json`;
74
+ writeFileSync(file, JSON.stringify(marker, null, 2));
75
+ }
76
+
77
+ export function writeRunningMarker(marker: RunningMarker): void {
78
+ mkdirSync(RUNNING_DIR, { recursive: true });
79
+ writeFileSync(`${RUNNING_DIR}/${marker.scheduleId}.json`, JSON.stringify(marker, null, 2));
80
+ }
81
+
82
+ export function readRunningMarker(scheduleId: string): RunningMarker | null {
83
+ const file = `${RUNNING_DIR}/${scheduleId}.json`;
84
+ if (!existsSync(file)) return null;
85
+ try {
86
+ return JSON.parse(readFileSync(file, 'utf-8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function clearRunningMarker(scheduleId: string): void {
93
+ try { unlinkSync(`${RUNNING_DIR}/${scheduleId}.json`); } catch { /* best-effort */ }
94
+ }
95
+
96
+ export function isProcessAlive(pid: number): boolean {
97
+ try { process.kill(pid, 0); return true; } catch { return false; }
98
+ }
@@ -0,0 +1,70 @@
1
+ import { CronExpressionParser } from 'cron-parser';
2
+ import type { Trigger } from './types.ts';
3
+
4
+ /**
5
+ * Returns the next fire time strictly after `from` (epoch ms), or null if
6
+ * the trigger has no more fires (e.g. a `once` trigger in the past).
7
+ */
8
+ export function computeNextRun(trigger: Trigger, from: number = Date.now()): number | null {
9
+ // Event triggers never fire on a timer — they wait for the event bus. No next run.
10
+ if (trigger.kind === 'event') return null;
11
+
12
+ if (trigger.kind === 'once') {
13
+ return trigger.at > from ? trigger.at : null;
14
+ }
15
+
16
+ if (trigger.kind === 'interval') {
17
+ if (!Number.isFinite(trigger.everyMs) || trigger.everyMs < 1000) return null;
18
+ return from + trigger.everyMs;
19
+ }
20
+
21
+ if (trigger.kind === 'cron') {
22
+ try {
23
+ const it = CronExpressionParser.parse(trigger.expr, {
24
+ currentDate: new Date(from),
25
+ tz: trigger.tz,
26
+ });
27
+ return it.next().getTime();
28
+ } catch (err) {
29
+ console.error(`[scheduler] invalid cron "${trigger.expr}":`, err);
30
+ return null;
31
+ }
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ export function computePrevRun(trigger: Trigger, from: number = Date.now()): number | null {
38
+ if (trigger.kind !== 'cron') return null;
39
+ try {
40
+ const it = CronExpressionParser.parse(trigger.expr, {
41
+ currentDate: new Date(from),
42
+ tz: trigger.tz,
43
+ });
44
+ return it.prev().getTime();
45
+ } catch (err) {
46
+ console.error(`[scheduler] invalid cron "${trigger.expr}":`, err);
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Validate a trigger; returns a human-readable error or null. */
52
+ export function validateTrigger(trigger: Trigger): string | null {
53
+ if (trigger.kind === 'event' && !trigger.source?.trim()) {
54
+ return 'Event trigger must have a source';
55
+ }
56
+ if (trigger.kind === 'once' && trigger.at <= Date.now()) {
57
+ return 'Once trigger must be in the future';
58
+ }
59
+ if (trigger.kind === 'cron') {
60
+ try {
61
+ CronExpressionParser.parse(trigger.expr, { tz: trigger.tz });
62
+ } catch (err: any) {
63
+ return `Invalid cron expression: ${err?.message ?? String(err)}`;
64
+ }
65
+ }
66
+ if (trigger.kind === 'interval' && trigger.everyMs < 1000) {
67
+ return 'Interval must be at least 1000ms';
68
+ }
69
+ return null;
70
+ }
@@ -0,0 +1,62 @@
1
+ /** Scheduled task definitions — see scheduler/engine.ts for the runtime. */
2
+
3
+ export type Trigger =
4
+ | { kind: 'once'; at: number /* epoch ms */ }
5
+ | { kind: 'interval'; everyMs: number }
6
+ | { kind: 'cron'; expr: string; tz: string }
7
+ /** Event-driven: fires when an external event with matching `source` (and optional
8
+ * `match` filter on the event payload) arrives on the event bus. Never timer-fired.
9
+ * Optional `throttle` suppresses duplicate fires (same dedup key) BEFORE a run is
10
+ * spawned — see EventThrottle. */
11
+ | { kind: 'event'; source: string; match?: Record<string, string>; throttle?: EventThrottle };
12
+
13
+ /** Per-trigger dedup: a fire is suppressed if an identical key fired within `windowSec`.
14
+ * The key is built from the named payload fields (dot-paths into the event payload),
15
+ * string-normalized (lowercased, digits→`#`, whitespace-collapsed) so values that differ
16
+ * only by timestamps/ids collapse together. Empty `byFields` keys on the source alone. */
17
+ export interface EventThrottle {
18
+ byFields: string[];
19
+ windowSec: number;
20
+ }
21
+
22
+ export type Task =
23
+ | { kind: 'prompt'; prompt?: string; promptFile?: string; model?: string }
24
+ | { kind: 'bash'; command: string; model?: string }
25
+ | { kind: 'job'; command: string };
26
+
27
+ /** Visibility: 'system' schedules + their sessions are shared with all whitelisted users; 'user' is private to createdBy. */
28
+ export type Scope = 'system' | 'user';
29
+
30
+ export interface ScheduleRunSummary {
31
+ at: number;
32
+ sessionId: string;
33
+ status: 'running' | 'ok' | 'error' | 'aborted';
34
+ error?: string;
35
+ }
36
+
37
+ export interface CompletionMarker {
38
+ completedAt: number;
39
+ triggeredBy: 'scheduler' | 'ssh' | 'api' | 'manual';
40
+ scheduleId: string;
41
+ }
42
+
43
+ export interface RunningMarker {
44
+ pid: number;
45
+ startedAt: number;
46
+ scheduleId: string;
47
+ }
48
+
49
+ export interface Schedule {
50
+ id: string;
51
+ name: string;
52
+ enabled: boolean;
53
+ trigger: Trigger;
54
+ task: Task;
55
+ scope: Scope;
56
+ createdBy: { uid: string; email: string };
57
+ createdAt: number;
58
+ updatedAt: number;
59
+ nextRun?: number;
60
+ lastRun?: ScheduleRunSummary;
61
+ runCount: number;
62
+ }
@@ -0,0 +1,45 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+
3
+ /**
4
+ * Run a simple SDK query that returns text only (no tools).
5
+ * Handles the CLAUDECODE nested-session guard automatically.
6
+ */
7
+ export async function runTextQuery(opts: {
8
+ prompt: string;
9
+ model?: string;
10
+ maxTurns?: number;
11
+ systemPrompt?: string;
12
+ }): Promise<string> {
13
+ const saved = process.env.CLAUDECODE;
14
+ delete process.env.CLAUDECODE;
15
+
16
+ try {
17
+ let result = '';
18
+ const model = opts.model || 'haiku';
19
+ for await (const ev of query({
20
+ prompt: opts.prompt,
21
+ options: {
22
+ model,
23
+ maxTurns: opts.maxTurns || 1,
24
+ systemPrompt: opts.systemPrompt,
25
+ },
26
+ })) {
27
+ const m = ev as any;
28
+ if (m.type === 'assistant' && m.message?.content) {
29
+ for (const block of m.message.content) {
30
+ if (typeof block === 'object' && 'text' in block) result += block.text;
31
+ }
32
+ } else if (m.type === 'result' && m.usage) {
33
+ // These one-shot summarizer/title calls have unique inputs (no shared
34
+ // prefix) so they're inherently uncacheable — logged for token-volume
35
+ // visibility so the org-wide cache picture isn't blind to this fleet.
36
+ const u = m.usage;
37
+ const inTok = (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.input_tokens ?? 0);
38
+ console.log(`[sdk] textQuery: in=${inTok} out=${u.output_tokens ?? 0} cacheRead=${u.cache_read_input_tokens ?? 0} model=${model}`);
39
+ }
40
+ }
41
+ return result;
42
+ } finally {
43
+ if (saved !== undefined) process.env.CLAUDECODE = saved;
44
+ }
45
+ }