clementine-agent 1.18.127 → 1.18.129
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.
- package/dist/agent/schedule-registry.d.ts +77 -0
- package/dist/agent/schedule-registry.js +147 -0
- package/dist/cli/cron.js +30 -0
- package/dist/cli/dashboard.js +576 -25
- package/dist/gateway/cron-scheduler.d.ts +13 -1
- package/dist/gateway/cron-scheduler.js +81 -17
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule registry — 1.18.129.
|
|
3
|
+
*
|
|
4
|
+
* The Anthropic-pure architectural shift: skills stay vanilla SKILL.md
|
|
5
|
+
* folders, scheduling lives in a separate tiny registry. Today's "fat
|
|
6
|
+
* cron" model in CRON.md duplicated ~70% of the skill schema (prompt,
|
|
7
|
+
* tools, MCP allowlists, work_dir, success criteria); this registry
|
|
8
|
+
* replaces all that with a thin {skillName → schedule} map.
|
|
9
|
+
*
|
|
10
|
+
* Storage: a single JSON file at `~/.clementine/schedules.json` with
|
|
11
|
+
* the shape:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* "morning-briefing": {
|
|
15
|
+
* "schedule": "0 7 * * 1-5",
|
|
16
|
+
* "enabled": true,
|
|
17
|
+
* "agentSlug": null,
|
|
18
|
+
* "addedAt": "2026-05-08T...",
|
|
19
|
+
* "lastModifiedAt": "2026-05-08T..."
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* The cron scheduler reads this alongside CRON.md and emits one
|
|
24
|
+
* CronJobDefinition per scheduled skill. The runtime path is unchanged
|
|
25
|
+
* — the skill body becomes the prompt via the existing buildSkillContext
|
|
26
|
+
* pipeline, no special case.
|
|
27
|
+
*
|
|
28
|
+
* Coexists with CRON.md indefinitely. Both formats run today; new work
|
|
29
|
+
* goes through this registry. Phase 3 ships the migrator that converts
|
|
30
|
+
* legacy crons to scheduled skills.
|
|
31
|
+
*/
|
|
32
|
+
export interface ScheduleEntry {
|
|
33
|
+
/** Skill slug — must match a skill in the catalog. The runtime auto-pins
|
|
34
|
+
* this skill, so its body becomes the prompt at fire-time. */
|
|
35
|
+
skillName: string;
|
|
36
|
+
/** Cron expression. Empty / falsy = no auto-fire (still appears on
|
|
37
|
+
* Tasks page so the user can edit it). */
|
|
38
|
+
schedule: string;
|
|
39
|
+
/** When false the scheduler skips it. Lets users pause without losing
|
|
40
|
+
* the schedule definition. */
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
/** When set, the skill runs as the named hired agent (Sasha, Ross,
|
|
43
|
+
* Nora, etc.). null/undefined = Clementine. Per-agent skills load
|
|
44
|
+
* from `agents/<slug>/skills/` first; the runtime resolves precedence. */
|
|
45
|
+
agentSlug?: string | null;
|
|
46
|
+
/** ISO timestamp of when the schedule was first created. */
|
|
47
|
+
addedAt?: string;
|
|
48
|
+
/** ISO timestamp of the last edit. */
|
|
49
|
+
lastModifiedAt?: string;
|
|
50
|
+
}
|
|
51
|
+
/** On-disk shape — keyed by skill name for O(1) lookup + simple merge. */
|
|
52
|
+
export type ScheduleFile = Record<string, Omit<ScheduleEntry, 'skillName'>>;
|
|
53
|
+
/**
|
|
54
|
+
* Read every schedule entry as a flat array. Each entry includes its
|
|
55
|
+
* skill name so callers don't have to reconstruct it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function listSchedules(): ScheduleEntry[];
|
|
58
|
+
/** Read one entry by skill name. Returns null when not scheduled. */
|
|
59
|
+
export declare function getSchedule(skillName: string): ScheduleEntry | null;
|
|
60
|
+
export interface SetScheduleInput {
|
|
61
|
+
schedule: string;
|
|
62
|
+
enabled?: boolean;
|
|
63
|
+
agentSlug?: string | null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Upsert a schedule for a skill. New entries get `addedAt`; existing
|
|
67
|
+
* entries get `lastModifiedAt` updated. Returns the resulting entry so
|
|
68
|
+
* the dashboard can re-render without a re-fetch.
|
|
69
|
+
*/
|
|
70
|
+
export declare function setSchedule(skillName: string, input: SetScheduleInput): ScheduleEntry;
|
|
71
|
+
/** Drop the entry for a skill. No-op when nothing is scheduled. */
|
|
72
|
+
export declare function removeSchedule(skillName: string): void;
|
|
73
|
+
/** Toggle the enabled flag. Skill stays in the registry; just won't
|
|
74
|
+
* fire while disabled. Caller-side convenience to avoid re-passing
|
|
75
|
+
* schedule + agentSlug just to flip the boolean. */
|
|
76
|
+
export declare function enableSchedule(skillName: string, enabled: boolean): ScheduleEntry | null;
|
|
77
|
+
//# sourceMappingURL=schedule-registry.d.ts.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule registry — 1.18.129.
|
|
3
|
+
*
|
|
4
|
+
* The Anthropic-pure architectural shift: skills stay vanilla SKILL.md
|
|
5
|
+
* folders, scheduling lives in a separate tiny registry. Today's "fat
|
|
6
|
+
* cron" model in CRON.md duplicated ~70% of the skill schema (prompt,
|
|
7
|
+
* tools, MCP allowlists, work_dir, success criteria); this registry
|
|
8
|
+
* replaces all that with a thin {skillName → schedule} map.
|
|
9
|
+
*
|
|
10
|
+
* Storage: a single JSON file at `~/.clementine/schedules.json` with
|
|
11
|
+
* the shape:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* "morning-briefing": {
|
|
15
|
+
* "schedule": "0 7 * * 1-5",
|
|
16
|
+
* "enabled": true,
|
|
17
|
+
* "agentSlug": null,
|
|
18
|
+
* "addedAt": "2026-05-08T...",
|
|
19
|
+
* "lastModifiedAt": "2026-05-08T..."
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* The cron scheduler reads this alongside CRON.md and emits one
|
|
24
|
+
* CronJobDefinition per scheduled skill. The runtime path is unchanged
|
|
25
|
+
* — the skill body becomes the prompt via the existing buildSkillContext
|
|
26
|
+
* pipeline, no special case.
|
|
27
|
+
*
|
|
28
|
+
* Coexists with CRON.md indefinitely. Both formats run today; new work
|
|
29
|
+
* goes through this registry. Phase 3 ships the migrator that converts
|
|
30
|
+
* legacy crons to scheduled skills.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
// Resolve lazily on each call so test environments (which override
|
|
36
|
+
// CLEMENTINE_HOME inside beforeEach) see the fresh value rather than the
|
|
37
|
+
// value snapshot at module-load time. Mirrors skill-suppressions.ts.
|
|
38
|
+
function baseDir() {
|
|
39
|
+
return process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
40
|
+
}
|
|
41
|
+
function schedulesPath() {
|
|
42
|
+
return path.join(baseDir(), 'schedules.json');
|
|
43
|
+
}
|
|
44
|
+
function readFile() {
|
|
45
|
+
const filePath = schedulesPath();
|
|
46
|
+
if (!existsSync(filePath))
|
|
47
|
+
return {};
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
50
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
51
|
+
return {};
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
54
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
55
|
+
continue;
|
|
56
|
+
const e = entry;
|
|
57
|
+
// Tolerate partially-populated entries (a hand-edited file might
|
|
58
|
+
// have only `schedule` set). Default everything else.
|
|
59
|
+
out[name] = {
|
|
60
|
+
schedule: typeof e.schedule === 'string' ? e.schedule : '',
|
|
61
|
+
enabled: e.enabled !== false, // default true
|
|
62
|
+
agentSlug: typeof e.agentSlug === 'string' ? e.agentSlug : null,
|
|
63
|
+
addedAt: typeof e.addedAt === 'string' ? e.addedAt : undefined,
|
|
64
|
+
lastModifiedAt: typeof e.lastModifiedAt === 'string' ? e.lastModifiedAt : undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Malformed JSON — never throw out of the registry. Worst case the
|
|
71
|
+
// user re-creates entries via the UI; their CRON.md jobs keep firing.
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function writeFile(data) {
|
|
76
|
+
const dir = baseDir();
|
|
77
|
+
if (!existsSync(dir))
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
// Sorted keys → deterministic on disk → friendly to git users who
|
|
80
|
+
// version-control their ~/.clementine/.
|
|
81
|
+
const sorted = {};
|
|
82
|
+
for (const k of Object.keys(data).sort())
|
|
83
|
+
sorted[k] = data[k];
|
|
84
|
+
writeFileSync(schedulesPath(), JSON.stringify(sorted, null, 2));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read every schedule entry as a flat array. Each entry includes its
|
|
88
|
+
* skill name so callers don't have to reconstruct it.
|
|
89
|
+
*/
|
|
90
|
+
export function listSchedules() {
|
|
91
|
+
const file = readFile();
|
|
92
|
+
return Object.entries(file).map(([skillName, entry]) => ({ skillName, ...entry }));
|
|
93
|
+
}
|
|
94
|
+
/** Read one entry by skill name. Returns null when not scheduled. */
|
|
95
|
+
export function getSchedule(skillName) {
|
|
96
|
+
const file = readFile();
|
|
97
|
+
const entry = file[skillName];
|
|
98
|
+
if (!entry)
|
|
99
|
+
return null;
|
|
100
|
+
return { skillName, ...entry };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Upsert a schedule for a skill. New entries get `addedAt`; existing
|
|
104
|
+
* entries get `lastModifiedAt` updated. Returns the resulting entry so
|
|
105
|
+
* the dashboard can re-render without a re-fetch.
|
|
106
|
+
*/
|
|
107
|
+
export function setSchedule(skillName, input) {
|
|
108
|
+
if (!skillName || typeof skillName !== 'string') {
|
|
109
|
+
throw new Error('setSchedule: skillName required');
|
|
110
|
+
}
|
|
111
|
+
const file = readFile();
|
|
112
|
+
const existing = file[skillName];
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const entry = {
|
|
115
|
+
schedule: input.schedule ?? '',
|
|
116
|
+
enabled: input.enabled !== false,
|
|
117
|
+
agentSlug: input.agentSlug ?? null,
|
|
118
|
+
addedAt: existing?.addedAt ?? now,
|
|
119
|
+
lastModifiedAt: now,
|
|
120
|
+
};
|
|
121
|
+
file[skillName] = entry;
|
|
122
|
+
writeFile(file);
|
|
123
|
+
return { skillName, ...entry };
|
|
124
|
+
}
|
|
125
|
+
/** Drop the entry for a skill. No-op when nothing is scheduled. */
|
|
126
|
+
export function removeSchedule(skillName) {
|
|
127
|
+
const file = readFile();
|
|
128
|
+
if (!(skillName in file))
|
|
129
|
+
return;
|
|
130
|
+
delete file[skillName];
|
|
131
|
+
writeFile(file);
|
|
132
|
+
}
|
|
133
|
+
/** Toggle the enabled flag. Skill stays in the registry; just won't
|
|
134
|
+
* fire while disabled. Caller-side convenience to avoid re-passing
|
|
135
|
+
* schedule + agentSlug just to flip the boolean. */
|
|
136
|
+
export function enableSchedule(skillName, enabled) {
|
|
137
|
+
const file = readFile();
|
|
138
|
+
const entry = file[skillName];
|
|
139
|
+
if (!entry)
|
|
140
|
+
return null;
|
|
141
|
+
entry.enabled = enabled;
|
|
142
|
+
entry.lastModifiedAt = new Date().toISOString();
|
|
143
|
+
file[skillName] = entry;
|
|
144
|
+
writeFile(file);
|
|
145
|
+
return { skillName, ...entry };
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=schedule-registry.js.map
|
package/dist/cli/cron.js
CHANGED
|
@@ -62,6 +62,36 @@ export async function cmdCronRun(jobName) {
|
|
|
62
62
|
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
63
63
|
const jobs = parseCronJobs();
|
|
64
64
|
let job = jobs.find((j) => j.name === jobName);
|
|
65
|
+
// 1.18.129 — Run-now fallback for skills that aren't on a schedule.
|
|
66
|
+
// The dashboard's "Run now" button on a skill detail pane calls this
|
|
67
|
+
// endpoint with the skill name. If the skill has no registry entry,
|
|
68
|
+
// synthesize a transient CronJobDefinition so the runtime can fire
|
|
69
|
+
// it once. The skill body becomes the prompt via buildSkillContext,
|
|
70
|
+
// same as scheduled-skill jobs.
|
|
71
|
+
if (!job) {
|
|
72
|
+
try {
|
|
73
|
+
const { getSkill } = await import('../agent/skill-store.js');
|
|
74
|
+
const skill = getSkill(jobName);
|
|
75
|
+
if (skill) {
|
|
76
|
+
const ext = (skill.frontmatter.clementine ?? {});
|
|
77
|
+
job = {
|
|
78
|
+
name: skill.frontmatter.name,
|
|
79
|
+
schedule: '0 0 1 1 *', // dummy — never auto-fires; run-now bypasses scheduling
|
|
80
|
+
prompt: '', // buildSkillContext injects the skill body
|
|
81
|
+
tier: 1,
|
|
82
|
+
enabled: true,
|
|
83
|
+
skills: [skill.frontmatter.name],
|
|
84
|
+
agentSlug: ext.agentSlug ?? undefined,
|
|
85
|
+
predictable: true,
|
|
86
|
+
source: 'scheduled-skill',
|
|
87
|
+
};
|
|
88
|
+
console.log(`(running skill "${jobName}" on demand — not yet scheduled)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Fall through to error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
65
95
|
if (!job) {
|
|
66
96
|
console.error(`Job not found: ${jobName}`);
|
|
67
97
|
console.error(`Available jobs: ${jobs.map((j) => j.name).join(', ') || '(none)'}`);
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -4618,6 +4618,101 @@ export async function cmdDashboard(opts) {
|
|
|
4618
4618
|
res.status(500).json({ ok: false, error: String(err) });
|
|
4619
4619
|
}
|
|
4620
4620
|
});
|
|
4621
|
+
// ── Schedule registry (1.18.129) ───────────────────────────────────
|
|
4622
|
+
// Anthropic-pure scheduling: skills stay vanilla, schedules live in
|
|
4623
|
+
// ~/.clementine/schedules.json. The cron scheduler reads this in
|
|
4624
|
+
// parseCronJobs alongside CRON.md, so any entry written here fires
|
|
4625
|
+
// through the same runtime as legacy crons.
|
|
4626
|
+
app.get('/api/schedules', async (_req, res) => {
|
|
4627
|
+
try {
|
|
4628
|
+
const { listSchedules } = await import('../agent/schedule-registry.js');
|
|
4629
|
+
res.json({ ok: true, schedules: listSchedules() });
|
|
4630
|
+
}
|
|
4631
|
+
catch (err) {
|
|
4632
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4633
|
+
}
|
|
4634
|
+
});
|
|
4635
|
+
app.get('/api/schedules/:skillName', async (req, res) => {
|
|
4636
|
+
try {
|
|
4637
|
+
const { getSchedule } = await import('../agent/schedule-registry.js');
|
|
4638
|
+
const entry = getSchedule(req.params.skillName);
|
|
4639
|
+
if (!entry)
|
|
4640
|
+
return res.status(404).json({ ok: false, error: 'not scheduled' });
|
|
4641
|
+
res.json({ ok: true, schedule: entry });
|
|
4642
|
+
}
|
|
4643
|
+
catch (err) {
|
|
4644
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4645
|
+
}
|
|
4646
|
+
});
|
|
4647
|
+
app.put('/api/schedules/:skillName', async (req, res) => {
|
|
4648
|
+
try {
|
|
4649
|
+
const skillName = req.params.skillName;
|
|
4650
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillName)) {
|
|
4651
|
+
return res.status(400).json({ ok: false, error: 'invalid skill name slug' });
|
|
4652
|
+
}
|
|
4653
|
+
const body = (req.body ?? {});
|
|
4654
|
+
if (typeof body.schedule !== 'string' || !body.schedule.trim()) {
|
|
4655
|
+
return res.status(400).json({ ok: false, error: 'schedule (cron expression) required' });
|
|
4656
|
+
}
|
|
4657
|
+
// Optional: validate agentSlug shape when provided.
|
|
4658
|
+
if (body.agentSlug && body.agentSlug !== null && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agentSlug)) {
|
|
4659
|
+
return res.status(400).json({ ok: false, error: 'invalid agentSlug' });
|
|
4660
|
+
}
|
|
4661
|
+
// Quick sanity check on cron expression — node-cron validates at
|
|
4662
|
+
// schedule-time anyway, but a 400 here is friendlier than a server
|
|
4663
|
+
// log buried in the daemon output.
|
|
4664
|
+
try {
|
|
4665
|
+
if (!cron.validate(body.schedule)) {
|
|
4666
|
+
return res.status(400).json({ ok: false, error: 'cron expression does not parse' });
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
catch {
|
|
4670
|
+
return res.status(400).json({ ok: false, error: 'cron expression does not parse' });
|
|
4671
|
+
}
|
|
4672
|
+
const { setSchedule } = await import('../agent/schedule-registry.js');
|
|
4673
|
+
const entry = setSchedule(skillName, {
|
|
4674
|
+
schedule: body.schedule,
|
|
4675
|
+
enabled: body.enabled !== false,
|
|
4676
|
+
agentSlug: body.agentSlug ?? null,
|
|
4677
|
+
});
|
|
4678
|
+
// Hot-reload: nudge the cron scheduler so the new entry fires on
|
|
4679
|
+
// its first tick instead of waiting for the next daemon restart.
|
|
4680
|
+
try {
|
|
4681
|
+
const gw = await getGateway();
|
|
4682
|
+
const sched = gw.cronScheduler;
|
|
4683
|
+
if (sched && typeof sched.reloadJobs === 'function')
|
|
4684
|
+
sched.reloadJobs();
|
|
4685
|
+
}
|
|
4686
|
+
catch { /* best-effort */ }
|
|
4687
|
+
res.json({ ok: true, schedule: entry });
|
|
4688
|
+
}
|
|
4689
|
+
catch (err) {
|
|
4690
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4691
|
+
}
|
|
4692
|
+
});
|
|
4693
|
+
app.delete('/api/schedules/:skillName', async (req, res) => {
|
|
4694
|
+
try {
|
|
4695
|
+
const { removeSchedule } = await import('../agent/schedule-registry.js');
|
|
4696
|
+
removeSchedule(req.params.skillName);
|
|
4697
|
+
try {
|
|
4698
|
+
const gw = await getGateway();
|
|
4699
|
+
const sched = gw.cronScheduler;
|
|
4700
|
+
if (sched && typeof sched.reloadJobs === 'function')
|
|
4701
|
+
sched.reloadJobs();
|
|
4702
|
+
}
|
|
4703
|
+
catch { /* best-effort */ }
|
|
4704
|
+
// Broadcast so any open Tasks tabs drop the row immediately
|
|
4705
|
+
// instead of waiting for the next refresh.
|
|
4706
|
+
try {
|
|
4707
|
+
broadcastEvent({ type: 'cron_deleted', data: { job: req.params.skillName, source: 'scheduled-skill' } });
|
|
4708
|
+
}
|
|
4709
|
+
catch { /* non-fatal */ }
|
|
4710
|
+
res.json({ ok: true });
|
|
4711
|
+
}
|
|
4712
|
+
catch (err) {
|
|
4713
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4714
|
+
}
|
|
4715
|
+
});
|
|
4621
4716
|
// ── Skill migration (legacy .md → folder/SKILL.md) ─────────────────
|
|
4622
4717
|
// Two endpoints: per-skill and bulk. Both wrap migrateLegacySkill /
|
|
4623
4718
|
// migrateAllLegacySkills from skill-store.ts. The original .md is
|
|
@@ -21254,6 +21349,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
21254
21349
|
</div>
|
|
21255
21350
|
</div>
|
|
21256
21351
|
|
|
21352
|
+
<!-- 1.18.128 — Project Context promoted to Basics. Was buried in
|
|
21353
|
+
the Scope tab where users were missing it. Selecting a project
|
|
21354
|
+
gives the task that project's CLAUDE.md, MCP config, and cwd —
|
|
21355
|
+
usually the single most impactful field after Prompt. -->
|
|
21356
|
+
<div class="cron-section-card" data-config-tab="basics">
|
|
21357
|
+
<h4>Project Context <span style="color:var(--text-muted);font-weight:normal;font-size:13px">(optional)</span></h4>
|
|
21358
|
+
<p class="cron-section-desc">Run this task inside a project directory. The agent picks up that project's <code>CLAUDE.md</code>, MCP config, and any context files alongside the cwd.</p>
|
|
21359
|
+
<div class="form-group">
|
|
21360
|
+
<select id="cron-workdir">
|
|
21361
|
+
<option value="">None — runs in default context</option>
|
|
21362
|
+
</select>
|
|
21363
|
+
<div class="form-hint">No projects yet? <a href="#" onclick="navigateTo(\\x27settings\\x27, { tab: \\x27projects\\x27 }); closeCronModal(); return false" style="color:var(--accent)">Add one →</a></div>
|
|
21364
|
+
</div>
|
|
21365
|
+
</div>
|
|
21366
|
+
|
|
21257
21367
|
<!-- Schedule -->
|
|
21258
21368
|
<div class="cron-section-card" data-config-tab="basics">
|
|
21259
21369
|
<h4>Schedule</h4>
|
|
@@ -21449,25 +21559,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
21449
21559
|
</div>
|
|
21450
21560
|
</div>
|
|
21451
21561
|
|
|
21452
|
-
<!-- ── Scope:
|
|
21562
|
+
<!-- ── Scope: extra read directories beyond the project cwd ── -->
|
|
21563
|
+
<!-- 1.18.128 — Project Context picker moved up to Basics. This
|
|
21564
|
+
section now only owns Additional read directories, which is
|
|
21565
|
+
a power-user feature anyway. -->
|
|
21453
21566
|
<div class="cron-section-card" data-config-tab="scope">
|
|
21454
21567
|
<h4>Scope</h4>
|
|
21455
|
-
<p class="cron-section-desc">
|
|
21456
|
-
<div class="form-row">
|
|
21457
|
-
<div class="form-group">
|
|
21458
|
-
<label class="form-label">Project Context <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
|
|
21459
|
-
<select id="cron-workdir">
|
|
21460
|
-
<option value="">None — runs in default context</option>
|
|
21461
|
-
</select>
|
|
21462
|
-
<div class="form-hint">Run inside a project directory. Agent gets that project's CLAUDE.md.</div>
|
|
21463
|
-
</div>
|
|
21464
|
-
</div>
|
|
21465
|
-
<!-- PRD Phase 1: read scope beyond cwd. One absolute path per line. -->
|
|
21568
|
+
<p class="cron-section-desc">Extra directories the agent gets read access to beyond the project cwd. Most tasks won't need this.</p>
|
|
21466
21569
|
<div class="form-row">
|
|
21467
21570
|
<div class="form-group" style="flex:1">
|
|
21468
21571
|
<label class="form-label">Additional read directories <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
|
|
21469
21572
|
<textarea id="cron-add-dirs" rows="2" placeholder="/Users/me/notes /Users/me/clients/acme" style="font-family:'JetBrains Mono',monospace;font-size:11px"></textarea>
|
|
21470
|
-
<div class="form-hint">One absolute path per line. The
|
|
21573
|
+
<div class="form-hint">One absolute path per line. The Project Context above already gives the agent its cwd; use this for extra read scope only.</div>
|
|
21471
21574
|
</div>
|
|
21472
21575
|
</div>
|
|
21473
21576
|
</div>
|
|
@@ -24836,6 +24939,17 @@ function renderScheduledTaskCard(task) {
|
|
|
24836
24939
|
var badges = '';
|
|
24837
24940
|
if (task.owner) badges += '<span class="badge badge-orange">' + esc(task.owner) + '</span>';
|
|
24838
24941
|
if (task.category) badges += '<span class="badge badge-gray" title="Category">' + esc(task.category) + '</span>';
|
|
24942
|
+
// 1.18.129 — source format badge. Scheduled skills are the new
|
|
24943
|
+
// canonical format (Anthropic-pure: skill folder + thin schedule
|
|
24944
|
+
// entry). Legacy CRON.md jobs carry their own prompt/tools/MCP and
|
|
24945
|
+
// duplicate ~70% of skill schema; they keep working but new tasks
|
|
24946
|
+
// should be skills.
|
|
24947
|
+
var defObj = task.definition || {};
|
|
24948
|
+
if (defObj.source === 'scheduled-skill') {
|
|
24949
|
+
badges += '<span class="badge" style="background:rgba(124,58,237,0.18);color:var(--purple);font-weight:600" title="Scheduled skill — fires the named SKILL.md folder on this cadence. Click to open the skill.">SKILL</span>';
|
|
24950
|
+
} else {
|
|
24951
|
+
badges += '<span class="badge badge-gray" title="Legacy CRON.md job. Carries its own prompt/tools/MCP. Convert to a scheduled skill when you can.">LEGACY CRON</span>';
|
|
24952
|
+
}
|
|
24839
24953
|
if (task.predictable === true) badges += '<span class="badge badge-green" title="Contract mode — runs with only the prompt + pinned skills/tools. No MEMORY.md, no auto-matched skills, no team comms injection at fire-time.">🔒 predictable</span>';
|
|
24840
24954
|
else if (task.predictable === false) badges += '<span class="badge badge-yellow" title="Dynamic mode — fire-time injects MEMORY.md, recent team activity, and auto-matched skills. Can drift from chat-time intent.">🔄 reads memory</span>';
|
|
24841
24955
|
if (task.mode === 'unleashed') badges += '<span class="badge badge-purple">long-running</span>';
|
|
@@ -24889,14 +25003,109 @@ function renderScheduledTaskCard(task) {
|
|
|
24889
25003
|
+ renderTrickTagChips(task)
|
|
24890
25004
|
+ '<div class="task-card-badges">' + badges + '</div>'
|
|
24891
25005
|
+ '<div class="task-card-actions">'
|
|
24892
|
-
|
|
25006
|
+
// 1.18.129 — SKILL-source tasks edit on the Skills page (the skill
|
|
25007
|
+
// folder IS the source of truth); legacy CRON.md tasks keep the
|
|
25008
|
+
// existing in-place editor.
|
|
25009
|
+
+ (defObj.source === 'scheduled-skill'
|
|
25010
|
+
? '<button class="btn-sm primary" onclick="navigateTo(\\x27skills\\x27, { skill: \\x27' + safeName + '\\x27 })" title="Edit this skill on the Skills page" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit skill</button>'
|
|
25011
|
+
: '<button class="btn-sm primary" onclick="openEditCronModal(\\x27' + safeName + '\\x27)" title="Edit task" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit</button>')
|
|
24893
25012
|
+ runOrCancelBtn
|
|
24894
25013
|
+ '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
|
|
24895
25014
|
+ '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
|
|
24896
|
-
+
|
|
25015
|
+
+ (defObj.source === 'scheduled-skill'
|
|
25016
|
+
? '<button class="btn-sm secondary btn-danger" onclick="unscheduleSkillFromCard(\\x27' + safeName + '\\x27)" title="Remove the schedule (skill stays)">Unschedule</button>'
|
|
25017
|
+
: '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>')
|
|
24897
25018
|
+ '</div></div>';
|
|
24898
25019
|
}
|
|
24899
25020
|
|
|
25021
|
+
// 1.18.129 — replace the "+ New Task" tile with a small dropdown that
|
|
25022
|
+
// nudges users toward the new "schedule a skill" path. Legacy cron
|
|
25023
|
+
// option stays for backward compat / power users with hand-rolled
|
|
25024
|
+
// CRON.md jobs they want to keep editing.
|
|
25025
|
+
function renderNewTaskMenu() {
|
|
25026
|
+
return ''
|
|
25027
|
+
+ '<div class="task-card-add" style="padding:14px;display:flex;flex-direction:column;align-items:stretch;gap:6px;cursor:default">'
|
|
25028
|
+
+ '<button class="btn-primary" onclick="openSchedulePickerForNew()" style="font-size:12px;padding:9px 12px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px">'
|
|
25029
|
+
+ '+ Schedule a skill'
|
|
25030
|
+
+ '</button>'
|
|
25031
|
+
+ '<button onclick="openCreateCronModal(getBuildCreateOwner())" style="font-size:11px;padding:5px 10px;border:none;border-radius:4px;background:transparent;color:var(--text-muted);cursor:pointer;text-align:center" title="Legacy CRON.md format — duplicates skill schema. Prefer Schedule a skill above unless you need fat-cron features.">use legacy cron format</button>'
|
|
25032
|
+
+ '</div>';
|
|
25033
|
+
}
|
|
25034
|
+
|
|
25035
|
+
// 1.18.129 — entry from the Tasks page: pick a skill, then open the
|
|
25036
|
+
// schedule overlay pre-wired for that skill. Two-step: skill picker
|
|
25037
|
+
// first (small modal), then the existing schedule-overlay flow.
|
|
25038
|
+
async function openSchedulePickerForNew() {
|
|
25039
|
+
// Fetch the skill catalog so users can scan + pick.
|
|
25040
|
+
var skills = [];
|
|
25041
|
+
try {
|
|
25042
|
+
var r = await apiFetch('/api/skills');
|
|
25043
|
+
var d = await r.json();
|
|
25044
|
+
if (r.ok && Array.isArray(d.skills)) skills = d.skills;
|
|
25045
|
+
} catch (_) { /* fall through to empty state */ }
|
|
25046
|
+
|
|
25047
|
+
var modal = document.getElementById('skill-picker-for-schedule-modal');
|
|
25048
|
+
if (!modal) {
|
|
25049
|
+
modal = document.createElement('div');
|
|
25050
|
+
modal.id = 'skill-picker-for-schedule-modal';
|
|
25051
|
+
modal.className = 'modal-overlay';
|
|
25052
|
+
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:none;align-items:center;justify-content:center;z-index:1000;padding:20px';
|
|
25053
|
+
document.body.appendChild(modal);
|
|
25054
|
+
}
|
|
25055
|
+
var rows = '';
|
|
25056
|
+
if (skills.length === 0) {
|
|
25057
|
+
rows = '<div class="empty-state" style="padding:18px">No skills yet. Create one on the Skills page first, then come back to schedule it.</div>';
|
|
25058
|
+
} else {
|
|
25059
|
+
rows = skills.map(function(s) {
|
|
25060
|
+
var fm = s.frontmatter || {};
|
|
25061
|
+
var ext = fm.clementine || {};
|
|
25062
|
+
var bodyLines = (s.body || '').split('\\n').length;
|
|
25063
|
+
var useCount = typeof ext.useCount === 'number' ? ext.useCount : 0;
|
|
25064
|
+
return ''
|
|
25065
|
+
+ '<div class="cap-picker-row" style="cursor:pointer" onclick="closeSkillPickerForSchedule(); openScheduleOverlayForSkill(\\x27' + jsStr(fm.name) + '\\x27, \\x27' + jsStr(ext.agentSlug || '') + '\\x27)">'
|
|
25066
|
+
+ '<div class="cap-picker-row-body">'
|
|
25067
|
+
+ '<div class="cap-picker-row-title">' + esc(fm.title || fm.name)
|
|
25068
|
+
+ ' <span style="color:var(--text-muted);font-weight:normal;font-size:10px">' + esc(fm.name) + '</span>'
|
|
25069
|
+
+ '</div>'
|
|
25070
|
+
+ (fm.description ? '<div class="cap-picker-row-desc">' + esc(fm.description) + '</div>' : '')
|
|
25071
|
+
+ '<div class="cap-picker-row-meta" style="display:flex;gap:10px;font-size:11px;color:var(--text-muted);margin-top:4px">'
|
|
25072
|
+
+ '<span>' + bodyLines + ' lines</span>'
|
|
25073
|
+
+ '<span>' + useCount + ' uses</span>'
|
|
25074
|
+
+ '</div>'
|
|
25075
|
+
+ '</div>'
|
|
25076
|
+
+ '</div>';
|
|
25077
|
+
}).join('');
|
|
25078
|
+
}
|
|
25079
|
+
modal.innerHTML =
|
|
25080
|
+
'<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(640px,95vw);max-height:80vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
|
|
25081
|
+
+ '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
|
|
25082
|
+
+ '<h3 style="margin:0;font-size:15px;font-weight:600">Pick a skill to schedule</h3>'
|
|
25083
|
+
+ '<button onclick="closeSkillPickerForSchedule()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer">✕</button>'
|
|
25084
|
+
+ '</div>'
|
|
25085
|
+
+ '<div style="flex:1;overflow-y:auto;padding:6px 0">' + rows + '</div>'
|
|
25086
|
+
+ '</div>';
|
|
25087
|
+
modal.style.display = 'flex';
|
|
25088
|
+
}
|
|
25089
|
+
|
|
25090
|
+
function closeSkillPickerForSchedule() {
|
|
25091
|
+
var m = document.getElementById('skill-picker-for-schedule-modal');
|
|
25092
|
+
if (m) m.style.display = 'none';
|
|
25093
|
+
}
|
|
25094
|
+
|
|
25095
|
+
// 1.18.129 — convenience: unschedule a skill directly from the Tasks
|
|
25096
|
+
// card without going through the schedule overlay.
|
|
25097
|
+
async function unscheduleSkillFromCard(skillName) {
|
|
25098
|
+
if (!confirm('Unschedule "' + skillName + '"? The skill stays in the catalog — only the schedule is removed.')) return;
|
|
25099
|
+
try {
|
|
25100
|
+
var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName), { method: 'DELETE' });
|
|
25101
|
+
if (!r.ok) { var d = await r.json(); toast(d.error || 'Failed', 'error'); return; }
|
|
25102
|
+
toast('Unscheduled "' + skillName + '"', 'success');
|
|
25103
|
+
if (typeof refreshCron === 'function') refreshCron();
|
|
25104
|
+
} catch (err) {
|
|
25105
|
+
toast('Failed: ' + err, 'error');
|
|
25106
|
+
}
|
|
25107
|
+
}
|
|
25108
|
+
|
|
24900
25109
|
function renderScheduledWorkflowCard(wf) {
|
|
24901
25110
|
var enabled = wf.enabled !== false;
|
|
24902
25111
|
var wfId = jsStr(wf.id);
|
|
@@ -26384,11 +26593,11 @@ async function refreshCron() {
|
|
|
26384
26593
|
} else {
|
|
26385
26594
|
emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No tasks across any agent.' : (ownerFilter ? 'No tasks for ' + ownerFilter + '.' : 'No tasks yet.');
|
|
26386
26595
|
}
|
|
26387
|
-
html +=
|
|
26596
|
+
html += renderNewTaskMenu()
|
|
26388
26597
|
+ '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">' + esc(emptyLabel) + '</div>';
|
|
26389
26598
|
} else {
|
|
26390
26599
|
html += filteredTasks.map(renderScheduledTaskCard).join('');
|
|
26391
|
-
html +=
|
|
26600
|
+
html += renderNewTaskMenu();
|
|
26392
26601
|
}
|
|
26393
26602
|
html += '</div>';
|
|
26394
26603
|
|
|
@@ -27096,9 +27305,14 @@ async function refreshProjects(preloaded) {
|
|
|
27096
27305
|
? '<div style="color:var(--accent);margin-bottom:4px;font-size:12px">' + esc(p.userDescription) + '</div>'
|
|
27097
27306
|
: '';
|
|
27098
27307
|
const idx = projectsData.indexOf(p);
|
|
27308
|
+
// 1.18.128 — "+ New task in this project" CTA: opens the cron creation
|
|
27309
|
+
// modal with the project pre-selected as Project Context. Closes the
|
|
27310
|
+
// mental gap between "I have a project with built-up context" and
|
|
27311
|
+
// "I need to schedule a task that uses it."
|
|
27312
|
+
const newTaskBtn = '<button class="btn btn-sm" style="font-size:11px" onclick="openCronModalForProject(' + idx + ')" title="Create a scheduled task that runs inside this project">+ New task</button>';
|
|
27099
27313
|
const linkBtn = p.linked
|
|
27100
|
-
? '<button class="btn btn-sm" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Edit</button> <button class="btn btn-sm btn-danger" style="font-size:11px" onclick="unlinkProjectByIdx(' + idx + ')">Unlink</button>'
|
|
27101
|
-
: '<button class="btn btn-sm btn-primary" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Link</button>';
|
|
27314
|
+
? newTaskBtn + ' <button class="btn btn-sm" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Edit</button> <button class="btn btn-sm btn-danger" style="font-size:11px" onclick="unlinkProjectByIdx(' + idx + ')">Unlink</button>'
|
|
27315
|
+
: newTaskBtn + ' <button class="btn btn-sm btn-primary" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Link</button>';
|
|
27102
27316
|
html += '<div class="card" style="cursor:default">'
|
|
27103
27317
|
+ '<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">'
|
|
27104
27318
|
+ '<strong>' + esc(p.name) + '</strong>'
|
|
@@ -27839,6 +28053,7 @@ async function showSkillDetail(name) {
|
|
|
27839
28053
|
}
|
|
27840
28054
|
detailEl.innerHTML = renderSkillDetail(d.skill);
|
|
27841
28055
|
if (typeof loadSkillSuppressionState === 'function') loadSkillSuppressionState(name);
|
|
28056
|
+
if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(name);
|
|
27842
28057
|
} catch (e) {
|
|
27843
28058
|
detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
|
|
27844
28059
|
}
|
|
@@ -27886,6 +28101,253 @@ async function toggleSkillSuppression(skillName, scope, suppressed) {
|
|
|
27886
28101
|
}
|
|
27887
28102
|
}
|
|
27888
28103
|
|
|
28104
|
+
// ── Skill schedule + Run now (1.18.129) ─────────────────────────────
|
|
28105
|
+
// The Anthropic-pure scheduling path: skills stay vanilla, schedules
|
|
28106
|
+
// live in ~/.clementine/schedules.json. This is the user's primary
|
|
28107
|
+
// surface for "this skill should fire on a cadence" — no cron modal.
|
|
28108
|
+
async function loadSkillScheduleState(skillName) {
|
|
28109
|
+
var statusEl = document.getElementById('skill-schedule-status');
|
|
28110
|
+
var btn = document.getElementById('skill-schedule-btn');
|
|
28111
|
+
if (!statusEl) return;
|
|
28112
|
+
try {
|
|
28113
|
+
var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName));
|
|
28114
|
+
if (r.status === 404) {
|
|
28115
|
+
statusEl.textContent = 'Not scheduled — runs on demand.';
|
|
28116
|
+
if (btn) btn.textContent = '⏰ Schedule';
|
|
28117
|
+
return;
|
|
28118
|
+
}
|
|
28119
|
+
var d = await r.json();
|
|
28120
|
+
if (!r.ok || !d.schedule) {
|
|
28121
|
+
statusEl.textContent = 'Not scheduled — runs on demand.';
|
|
28122
|
+
return;
|
|
28123
|
+
}
|
|
28124
|
+
var pretty = formatCronExpression ? formatCronExpression(d.schedule.schedule) : d.schedule.schedule;
|
|
28125
|
+
statusEl.textContent = (d.schedule.enabled ? '✅ Scheduled — ' : '⏸ Paused — ') + pretty;
|
|
28126
|
+
if (btn) btn.textContent = '⏰ Edit schedule';
|
|
28127
|
+
} catch (err) {
|
|
28128
|
+
statusEl.textContent = 'Schedule status unavailable.';
|
|
28129
|
+
}
|
|
28130
|
+
}
|
|
28131
|
+
|
|
28132
|
+
async function runSkillNow(skillName) {
|
|
28133
|
+
// The /api/cron/run/:job endpoint also runs unscheduled skills — the
|
|
28134
|
+
// CLI's cmdCronRun was extended in 1.18.129 to fall back to the skill
|
|
28135
|
+
// catalog when the name doesn't match a registered cron job. So one
|
|
28136
|
+
// endpoint handles both paths.
|
|
28137
|
+
var btn = document.getElementById('skill-run-now-btn');
|
|
28138
|
+
if (btn) { btn.disabled = true; btn.textContent = '⏳ Running…'; }
|
|
28139
|
+
try {
|
|
28140
|
+
var r = await apiFetch('/api/cron/run/' + encodeURIComponent(skillName), { method: 'POST' });
|
|
28141
|
+
if (r.status === 409) {
|
|
28142
|
+
toast('Already running. Wait for it to finish before firing again.', 'warn');
|
|
28143
|
+
return;
|
|
28144
|
+
}
|
|
28145
|
+
var d = await r.json();
|
|
28146
|
+
if (!r.ok) { toast(d.error || 'Run failed', 'error'); return; }
|
|
28147
|
+
toast('Started "' + skillName + '" — output streams to chat.', 'success');
|
|
28148
|
+
} catch (err) {
|
|
28149
|
+
toast('Failed: ' + err, 'error');
|
|
28150
|
+
} finally {
|
|
28151
|
+
if (btn) { btn.disabled = false; btn.textContent = '▶ Run now'; }
|
|
28152
|
+
}
|
|
28153
|
+
}
|
|
28154
|
+
|
|
28155
|
+
// Open the schedule overlay for a specific skill. The overlay is
|
|
28156
|
+
// pre-filled from the registry when a schedule already exists.
|
|
28157
|
+
async function openScheduleOverlayForSkill(skillName, agentSlug) {
|
|
28158
|
+
var modal = document.getElementById('schedule-skill-modal');
|
|
28159
|
+
if (!modal) {
|
|
28160
|
+
modal = document.createElement('div');
|
|
28161
|
+
modal.id = 'schedule-skill-modal';
|
|
28162
|
+
modal.className = 'modal-overlay';
|
|
28163
|
+
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:none;align-items:center;justify-content:center;z-index:1000;padding:20px';
|
|
28164
|
+
modal.innerHTML =
|
|
28165
|
+
'<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(540px,95vw);max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
|
|
28166
|
+
+ '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
|
|
28167
|
+
+ '<h3 id="ssm-title" style="margin:0;font-size:15px;font-weight:600">Schedule a skill</h3>'
|
|
28168
|
+
+ '<button onclick="closeScheduleSkillModal()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer">✕</button>'
|
|
28169
|
+
+ '</div>'
|
|
28170
|
+
+ '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
|
|
28171
|
+
+ '<input type="hidden" id="ssm-skill-name">'
|
|
28172
|
+
+ '<input type="hidden" id="ssm-agent-slug">'
|
|
28173
|
+
+ '<div style="font-size:12px;color:var(--text-muted);margin-bottom:14px">Skill: <code id="ssm-skill-display" style="background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-size:11px"></code></div>'
|
|
28174
|
+
+ '<label style="display:block;font-size:12px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">Schedule</label>'
|
|
28175
|
+
+ '<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">'
|
|
28176
|
+
+ '<select id="ssm-freq" onchange="ssmUpdateFromBuilder()" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)">'
|
|
28177
|
+
+ '<option value="daily">Every day</option>'
|
|
28178
|
+
+ '<option value="weekdays">Weekdays (Mon–Fri)</option>'
|
|
28179
|
+
+ '<option value="weekly">Weekly</option>'
|
|
28180
|
+
+ '<option value="hourly">Every N hours</option>'
|
|
28181
|
+
+ '<option value="minutes">Every N minutes</option>'
|
|
28182
|
+
+ '</select>'
|
|
28183
|
+
+ '<input type="number" id="ssm-interval" value="1" min="1" max="59" style="width:60px;padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);display:none" onchange="ssmUpdateFromBuilder()">'
|
|
28184
|
+
+ '<select id="ssm-day" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);display:none" onchange="ssmUpdateFromBuilder()">'
|
|
28185
|
+
+ '<option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option><option value="0">Sunday</option>'
|
|
28186
|
+
+ '</select>'
|
|
28187
|
+
+ '<select id="ssm-hour" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)" onchange="ssmUpdateFromBuilder()"></select>'
|
|
28188
|
+
+ '<select id="ssm-minute" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)" onchange="ssmUpdateFromBuilder()"></select>'
|
|
28189
|
+
+ '</div>'
|
|
28190
|
+
+ '<div style="font-size:11px;color:var(--text-muted);margin-bottom:14px">Cron: <code id="ssm-cron-preview" style="background:var(--bg-tertiary);padding:1px 5px;border-radius:3px"></code></div>'
|
|
28191
|
+
+ '<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);cursor:pointer;margin-bottom:8px">'
|
|
28192
|
+
+ '<input type="checkbox" id="ssm-enabled" checked> Enabled (uncheck to pause without losing the schedule)'
|
|
28193
|
+
+ '</label>'
|
|
28194
|
+
+ '<div id="ssm-error" style="display:none;color:var(--red);font-size:12px;margin-top:10px;padding:8px 10px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:6px"></div>'
|
|
28195
|
+
+ '</div>'
|
|
28196
|
+
+ '<div style="display:flex;justify-content:space-between;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
|
|
28197
|
+
+ '<button id="ssm-unschedule" onclick="unscheduleSkillFromOverlay()" style="padding:7px 14px;font-size:12px;border:1px solid var(--red);border-radius:6px;background:transparent;color:var(--red);cursor:pointer;display:none">Unschedule</button>'
|
|
28198
|
+
+ '<div style="flex:1"></div>'
|
|
28199
|
+
+ '<button onclick="closeScheduleSkillModal()" style="padding:7px 14px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer">Cancel</button>'
|
|
28200
|
+
+ '<button id="ssm-save" onclick="saveScheduleFromOverlay()" class="btn-primary" style="padding:7px 16px;font-size:13px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Save</button>'
|
|
28201
|
+
+ '</div>'
|
|
28202
|
+
+ '</div>';
|
|
28203
|
+
document.body.appendChild(modal);
|
|
28204
|
+
// Populate hour + minute dropdowns once.
|
|
28205
|
+
var hourSel = document.getElementById('ssm-hour');
|
|
28206
|
+
for (var h = 0; h < 24; h++) {
|
|
28207
|
+
var opt = document.createElement('option');
|
|
28208
|
+
opt.value = String(h);
|
|
28209
|
+
var hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
28210
|
+
var ampm = h < 12 ? 'AM' : 'PM';
|
|
28211
|
+
opt.textContent = hh + ':00 ' + ampm;
|
|
28212
|
+
if (h === 9) opt.selected = true;
|
|
28213
|
+
hourSel.appendChild(opt);
|
|
28214
|
+
}
|
|
28215
|
+
var minSel = document.getElementById('ssm-minute');
|
|
28216
|
+
[0, 15, 30, 45].forEach(function(m) {
|
|
28217
|
+
var opt = document.createElement('option');
|
|
28218
|
+
opt.value = String(m);
|
|
28219
|
+
opt.textContent = ':' + (m < 10 ? '0' + m : m);
|
|
28220
|
+
minSel.appendChild(opt);
|
|
28221
|
+
});
|
|
28222
|
+
}
|
|
28223
|
+
document.getElementById('ssm-skill-name').value = skillName;
|
|
28224
|
+
document.getElementById('ssm-agent-slug').value = agentSlug || '';
|
|
28225
|
+
document.getElementById('ssm-skill-display').textContent = skillName;
|
|
28226
|
+
// Try to load existing schedule.
|
|
28227
|
+
var existing = null;
|
|
28228
|
+
try {
|
|
28229
|
+
var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName));
|
|
28230
|
+
if (r.ok) { var d = await r.json(); existing = d.schedule; }
|
|
28231
|
+
} catch (_) { /* not scheduled */ }
|
|
28232
|
+
document.getElementById('ssm-title').textContent = existing ? 'Edit schedule: ' + skillName : 'Schedule: ' + skillName;
|
|
28233
|
+
document.getElementById('ssm-unschedule').style.display = existing ? '' : 'none';
|
|
28234
|
+
document.getElementById('ssm-enabled').checked = existing ? existing.enabled !== false : true;
|
|
28235
|
+
// Default to daily 9am for new entries; round-trip the cron expr if editing.
|
|
28236
|
+
if (existing && existing.schedule) {
|
|
28237
|
+
ssmInferBuilderFromCron(existing.schedule);
|
|
28238
|
+
} else {
|
|
28239
|
+
document.getElementById('ssm-freq').value = 'daily';
|
|
28240
|
+
document.getElementById('ssm-hour').value = '9';
|
|
28241
|
+
document.getElementById('ssm-minute').value = '0';
|
|
28242
|
+
ssmUpdateFromBuilder();
|
|
28243
|
+
}
|
|
28244
|
+
document.getElementById('ssm-error').style.display = 'none';
|
|
28245
|
+
modal.style.display = 'flex';
|
|
28246
|
+
}
|
|
28247
|
+
|
|
28248
|
+
function closeScheduleSkillModal() {
|
|
28249
|
+
var m = document.getElementById('schedule-skill-modal');
|
|
28250
|
+
if (m) m.style.display = 'none';
|
|
28251
|
+
}
|
|
28252
|
+
|
|
28253
|
+
// Build a cron expression from the picker fields. Mirrors the existing
|
|
28254
|
+
// schedule-builder in the cron modal but kept self-contained here so
|
|
28255
|
+
// the overlay doesn't depend on the cron-modal DOM existing.
|
|
28256
|
+
function ssmUpdateFromBuilder() {
|
|
28257
|
+
var freq = document.getElementById('ssm-freq').value;
|
|
28258
|
+
var hour = document.getElementById('ssm-hour').value || '9';
|
|
28259
|
+
var minute = document.getElementById('ssm-minute').value || '0';
|
|
28260
|
+
var day = document.getElementById('ssm-day').value || '1';
|
|
28261
|
+
var interval = document.getElementById('ssm-interval').value || '1';
|
|
28262
|
+
document.getElementById('ssm-day').style.display = freq === 'weekly' ? '' : 'none';
|
|
28263
|
+
document.getElementById('ssm-interval').style.display = (freq === 'hourly' || freq === 'minutes') ? '' : 'none';
|
|
28264
|
+
document.getElementById('ssm-hour').style.display = (freq === 'minutes' || freq === 'hourly') ? 'none' : '';
|
|
28265
|
+
document.getElementById('ssm-minute').style.display = freq === 'minutes' ? 'none' : '';
|
|
28266
|
+
var cron = '';
|
|
28267
|
+
switch (freq) {
|
|
28268
|
+
case 'daily': cron = minute + ' ' + hour + ' * * *'; break;
|
|
28269
|
+
case 'weekdays': cron = minute + ' ' + hour + ' * * 1-5'; break;
|
|
28270
|
+
case 'weekly': cron = minute + ' ' + hour + ' * * ' + day; break;
|
|
28271
|
+
case 'hourly': cron = '0 */' + interval + ' * * *'; break;
|
|
28272
|
+
case 'minutes': cron = '*/' + interval + ' * * * *'; break;
|
|
28273
|
+
}
|
|
28274
|
+
document.getElementById('ssm-cron-preview').textContent = cron;
|
|
28275
|
+
}
|
|
28276
|
+
|
|
28277
|
+
// Best-effort reverse: given a cron expr, set the picker fields to a
|
|
28278
|
+
// sensible default. Falls back to "daily 9am" if we can't infer.
|
|
28279
|
+
function ssmInferBuilderFromCron(cron) {
|
|
28280
|
+
var parts = (cron || '').trim().split(/\\s+/);
|
|
28281
|
+
if (parts.length !== 5) { document.getElementById('ssm-freq').value = 'daily'; ssmUpdateFromBuilder(); return; }
|
|
28282
|
+
var minute = parts[0], hour = parts[1], dow = parts[4];
|
|
28283
|
+
if (minute.indexOf('*/') === 0) {
|
|
28284
|
+
document.getElementById('ssm-freq').value = 'minutes';
|
|
28285
|
+
document.getElementById('ssm-interval').value = minute.slice(2);
|
|
28286
|
+
} else if (hour.indexOf('*/') === 0) {
|
|
28287
|
+
document.getElementById('ssm-freq').value = 'hourly';
|
|
28288
|
+
document.getElementById('ssm-interval').value = hour.slice(2);
|
|
28289
|
+
} else if (dow === '1-5') {
|
|
28290
|
+
document.getElementById('ssm-freq').value = 'weekdays';
|
|
28291
|
+
document.getElementById('ssm-hour').value = hour;
|
|
28292
|
+
document.getElementById('ssm-minute').value = minute;
|
|
28293
|
+
} else if (dow !== '*' && /^[0-6]$/.test(dow)) {
|
|
28294
|
+
document.getElementById('ssm-freq').value = 'weekly';
|
|
28295
|
+
document.getElementById('ssm-day').value = dow;
|
|
28296
|
+
document.getElementById('ssm-hour').value = hour;
|
|
28297
|
+
document.getElementById('ssm-minute').value = minute;
|
|
28298
|
+
} else {
|
|
28299
|
+
document.getElementById('ssm-freq').value = 'daily';
|
|
28300
|
+
document.getElementById('ssm-hour').value = hour;
|
|
28301
|
+
document.getElementById('ssm-minute').value = minute;
|
|
28302
|
+
}
|
|
28303
|
+
ssmUpdateFromBuilder();
|
|
28304
|
+
}
|
|
28305
|
+
|
|
28306
|
+
async function saveScheduleFromOverlay() {
|
|
28307
|
+
var skillName = document.getElementById('ssm-skill-name').value;
|
|
28308
|
+
var agentSlug = document.getElementById('ssm-agent-slug').value;
|
|
28309
|
+
var schedule = document.getElementById('ssm-cron-preview').textContent;
|
|
28310
|
+
var enabled = document.getElementById('ssm-enabled').checked;
|
|
28311
|
+
var errEl = document.getElementById('ssm-error');
|
|
28312
|
+
var saveBtn = document.getElementById('ssm-save');
|
|
28313
|
+
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; }
|
|
28314
|
+
try {
|
|
28315
|
+
var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName), {
|
|
28316
|
+
method: 'PUT',
|
|
28317
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28318
|
+
body: JSON.stringify({ schedule: schedule, enabled: enabled, agentSlug: agentSlug || null }),
|
|
28319
|
+
});
|
|
28320
|
+
var d = await r.json();
|
|
28321
|
+
if (!r.ok) {
|
|
28322
|
+
if (errEl) { errEl.textContent = d.error || ('HTTP ' + r.status); errEl.style.display = ''; }
|
|
28323
|
+
return;
|
|
28324
|
+
}
|
|
28325
|
+
closeScheduleSkillModal();
|
|
28326
|
+
toast('Saved schedule for "' + skillName + '"', 'success');
|
|
28327
|
+
if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(skillName);
|
|
28328
|
+
if (currentPage === 'build' && typeof refreshCron === 'function') refreshCron();
|
|
28329
|
+
} catch (err) {
|
|
28330
|
+
if (errEl) { errEl.textContent = String(err); errEl.style.display = ''; }
|
|
28331
|
+
} finally {
|
|
28332
|
+
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; }
|
|
28333
|
+
}
|
|
28334
|
+
}
|
|
28335
|
+
|
|
28336
|
+
async function unscheduleSkillFromOverlay() {
|
|
28337
|
+
var skillName = document.getElementById('ssm-skill-name').value;
|
|
28338
|
+
if (!confirm('Unschedule "' + skillName + '"? The skill stays — only the schedule is removed.')) return;
|
|
28339
|
+
try {
|
|
28340
|
+
var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName), { method: 'DELETE' });
|
|
28341
|
+
if (!r.ok) { var d = await r.json(); toast(d.error || 'Failed', 'error'); return; }
|
|
28342
|
+
closeScheduleSkillModal();
|
|
28343
|
+
toast('Unscheduled "' + skillName + '"', 'success');
|
|
28344
|
+
if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(skillName);
|
|
28345
|
+
if (currentPage === 'build' && typeof refreshCron === 'function') refreshCron();
|
|
28346
|
+
} catch (err) {
|
|
28347
|
+
toast('Failed: ' + err, 'error');
|
|
28348
|
+
}
|
|
28349
|
+
}
|
|
28350
|
+
|
|
27889
28351
|
// ── Skill detail pane ────────────────────────────────────────────────
|
|
27890
28352
|
// Renders a single skill in the right pane. Sections, in order:
|
|
27891
28353
|
// 1. Header (name + 3 badges + description + file path)
|
|
@@ -27938,6 +28400,20 @@ function renderSkillDetail(s) {
|
|
|
27938
28400
|
html += '</label>';
|
|
27939
28401
|
html += '<span id="skill-suppress-status" style="font-size:11px;color:var(--text-muted);margin-left:auto"></span>';
|
|
27940
28402
|
html += '</div>';
|
|
28403
|
+
|
|
28404
|
+
// 1.18.129 — Schedule + Run-now action row. Architectural shift:
|
|
28405
|
+
// skills can be invoked on demand (Run now) OR fired on a schedule
|
|
28406
|
+
// (the registry maps {skill → cron expr}). Replaces the "create a
|
|
28407
|
+
// cron task and pin this skill" indirection. The exact schedule
|
|
28408
|
+
// state (scheduled? when?) is loaded async after the pane mounts.
|
|
28409
|
+
html += '<div id="skill-schedule-row" data-skill="' + esc(fm.name) + '" style="margin-top:10px;padding:12px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">';
|
|
28410
|
+
html += '<div style="flex:1;min-width:200px">';
|
|
28411
|
+
html += '<div style="font-size:12px;font-weight:500;color:var(--text-primary);margin-bottom:2px">Run this skill</div>';
|
|
28412
|
+
html += '<div id="skill-schedule-status" style="font-size:11px;color:var(--text-muted)">Loading schedule…</div>';
|
|
28413
|
+
html += '</div>';
|
|
28414
|
+
html += '<button class="btn-sm btn-primary" id="skill-run-now-btn" onclick="runSkillNow(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:11px;padding:6px 12px;display:inline-flex;align-items:center;gap:6px" title="Fire this skill once, right now">▶ Run now</button>';
|
|
28415
|
+
html += '<button class="btn-sm" id="skill-schedule-btn" onclick="openScheduleOverlayForSkill(\\x27' + jsStr(fm.name) + '\\x27, \\x27' + jsStr(s.frontmatter.clementine?.agentSlug ?? '') + '\\x27)" style="font-size:11px;padding:6px 12px" title="Schedule this skill to run automatically">⏰ Schedule</button>';
|
|
28416
|
+
html += '</div>';
|
|
27941
28417
|
html += '</div>';
|
|
27942
28418
|
|
|
27943
28419
|
// ── 2. Validation warnings (if any)
|
|
@@ -28592,13 +29068,50 @@ async function loadSkillsCatalog() {
|
|
|
28592
29068
|
|
|
28593
29069
|
async function loadMcpCatalog() {
|
|
28594
29070
|
if (_mcpCatalog) return _mcpCatalog;
|
|
29071
|
+
// 1.18.128 — merge Composio toolkits into the picker. discoverMcpServers()
|
|
29072
|
+
// only sees Claude Desktop / Claude Code / Extensions / user-managed
|
|
29073
|
+
// config, but the runtime ALSO injects every connected Composio toolkit
|
|
29074
|
+
// via buildExtraMcpForRunAgent. The picker was blind to all that — users
|
|
29075
|
+
// would scroll and not see Gmail, Slack, Salesforce, etc., even though
|
|
29076
|
+
// those servers fire correctly when the cron runs. This fixes the picker
|
|
29077
|
+
// to match runtime reality.
|
|
29078
|
+
var servers = [];
|
|
28595
29079
|
try {
|
|
28596
29080
|
var r = await apiFetch('/api/mcp-servers');
|
|
28597
29081
|
var d = await r.json();
|
|
28598
|
-
|
|
28599
|
-
|
|
28600
|
-
|
|
28601
|
-
}
|
|
29082
|
+
servers = (d.servers || []).map(function(s) {
|
|
29083
|
+
return Object.assign({}, s, { _origin: s.source || 'config' });
|
|
29084
|
+
});
|
|
29085
|
+
} catch (_) { servers = []; }
|
|
29086
|
+
try {
|
|
29087
|
+
var rc = await apiFetch('/api/composio/toolkits');
|
|
29088
|
+
var dc = await rc.json();
|
|
29089
|
+
if (dc && dc.enabled !== false && Array.isArray(dc.toolkits)) {
|
|
29090
|
+
// Only show toolkits with at least one ACTIVE connection — those are
|
|
29091
|
+
// the ones the runtime can actually call. Auth-config-only toolkits
|
|
29092
|
+
// would fail tool calls, so showing them here would mislead.
|
|
29093
|
+
var connected = dc.toolkits.filter(function(t) {
|
|
29094
|
+
return Array.isArray(t.connections) && t.connections.some(function(c) { return c && c.status === 'ACTIVE'; });
|
|
29095
|
+
});
|
|
29096
|
+
var existingNames = new Set(servers.map(function(s) { return s.name; }));
|
|
29097
|
+
for (var i = 0; i < connected.length; i++) {
|
|
29098
|
+
var t = connected[i];
|
|
29099
|
+
if (existingNames.has(t.slug)) continue; // dedup — local config wins
|
|
29100
|
+
servers.push({
|
|
29101
|
+
name: t.slug,
|
|
29102
|
+
type: 'composio',
|
|
29103
|
+
description: t.description || (t.displayName + ' (via Composio)'),
|
|
29104
|
+
enabled: true,
|
|
29105
|
+
source: 'composio',
|
|
29106
|
+
_origin: 'composio',
|
|
29107
|
+
_displayName: t.displayName,
|
|
29108
|
+
_toolCount: t.toolCount,
|
|
29109
|
+
_connectionCount: (t.connections || []).filter(function(c) { return c && c.status === 'ACTIVE'; }).length,
|
|
29110
|
+
});
|
|
29111
|
+
}
|
|
29112
|
+
}
|
|
29113
|
+
} catch (_) { /* Composio not enabled / API down — picker still works */ }
|
|
29114
|
+
_mcpCatalog = { servers: servers };
|
|
28602
29115
|
return _mcpCatalog;
|
|
28603
29116
|
}
|
|
28604
29117
|
|
|
@@ -28739,13 +29252,26 @@ function renderMcpPickerList() {
|
|
|
28739
29252
|
listEl.innerHTML = servers.slice(0, 50).map(function(s) {
|
|
28740
29253
|
var sel = _cronSelectedMcp.indexOf(s.name) !== -1;
|
|
28741
29254
|
var enabledTag = s.enabled === false ? ' <span style="color:var(--text-muted);font-size:10px">(disabled)</span>' : '';
|
|
29255
|
+
// 1.18.128 — distinct badge for Composio-sourced toolkits so users can
|
|
29256
|
+
// see at a glance which servers come from local config vs the
|
|
29257
|
+
// Composio account, plus a connection count for managed accounts.
|
|
29258
|
+
var sourceBadge = '';
|
|
29259
|
+
if (s._origin === 'composio') {
|
|
29260
|
+
var connTxt = s._connectionCount ? s._connectionCount + ' conn' : 'connected';
|
|
29261
|
+
sourceBadge = ' <span style="background:rgba(124,58,237,0.12);color:var(--purple);font-size:9px;padding:1px 6px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em" title="Sourced from Composio account">composio</span>'
|
|
29262
|
+
+ ' <span style="color:var(--text-muted);font-size:10px">' + esc(connTxt) + '</span>';
|
|
29263
|
+
} else if (s.source && s.source !== 'auto-detected') {
|
|
29264
|
+
sourceBadge = ' <span style="background:var(--bg-tertiary);color:var(--text-muted);font-size:9px;padding:1px 6px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em">' + esc(s.source) + '</span>';
|
|
29265
|
+
}
|
|
29266
|
+
var displayName = s._displayName || s.name;
|
|
28742
29267
|
return '<div class="cap-picker-row mcp' + (sel ? ' selected' : '') + '" onclick="addMcpToTrick(\\x27' + jsStr(s.name) + '\\x27)">'
|
|
28743
29268
|
+ '<div class="cap-picker-row-body">'
|
|
28744
|
-
+ '<div class="cap-picker-row-title">' + esc(
|
|
29269
|
+
+ '<div class="cap-picker-row-title">' + esc(displayName)
|
|
29270
|
+
+ (displayName !== s.name ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:10px">' + esc(s.name) + '</span>' : '')
|
|
29271
|
+
+ enabledTag + sourceBadge
|
|
28745
29272
|
+ (sel ? ' <span style="color:var(--purple);font-size:11px">✓ allowed</span>' : '')
|
|
28746
29273
|
+ '</div>'
|
|
28747
29274
|
+ (s.description ? '<div class="cap-picker-row-desc">' + esc(s.description) + '</div>' : '')
|
|
28748
|
-
+ (s.source ? '<div class="cap-picker-row-meta">source: ' + esc(s.source) + '</div>' : '')
|
|
28749
29275
|
+ '</div></div>';
|
|
28750
29276
|
}).join('');
|
|
28751
29277
|
}
|
|
@@ -29406,6 +29932,31 @@ async function enablePredictableFromBanner() {
|
|
|
29406
29932
|
}
|
|
29407
29933
|
}
|
|
29408
29934
|
|
|
29935
|
+
// 1.18.128 — open the cron modal pre-wired to a project. Called from the
|
|
29936
|
+
// "+ New task" button on each project card. Pre-fills cron-workdir and
|
|
29937
|
+
// suggests a name based on the project so the user only has to fill in
|
|
29938
|
+
// the prompt + schedule. The dropdown is populated by refreshProjects()
|
|
29939
|
+
// at page load, so the pre-filled value resolves cleanly to one of the
|
|
29940
|
+
// existing options.
|
|
29941
|
+
function openCronModalForProject(projectIdx) {
|
|
29942
|
+
var p = (typeof projectsData !== 'undefined' && Array.isArray(projectsData)) ? projectsData[projectIdx] : null;
|
|
29943
|
+
if (!p) { toast('Project not found.', 'error'); return; }
|
|
29944
|
+
openCreateCronModal();
|
|
29945
|
+
// Pre-set the project context. dropdown options were populated by
|
|
29946
|
+
// refreshProjects on page load, so the value matches one of them.
|
|
29947
|
+
var sel = document.getElementById('cron-workdir');
|
|
29948
|
+
if (sel) sel.value = p.path;
|
|
29949
|
+
// Suggest a task name based on the project — replaces non-slug chars
|
|
29950
|
+
// and truncates so the slug rule passes. User can override.
|
|
29951
|
+
var slugBase = (p.name || 'project').toLowerCase()
|
|
29952
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
29953
|
+
.replace(/^-+|-+$/g, '')
|
|
29954
|
+
.slice(0, 40);
|
|
29955
|
+
var nameInput = document.getElementById('cron-name');
|
|
29956
|
+
if (nameInput && !nameInput.value) nameInput.value = slugBase + '-task';
|
|
29957
|
+
toast('Project Context set to "' + (p.name || p.path) + '" — fill in the prompt and schedule.', 'info');
|
|
29958
|
+
}
|
|
29959
|
+
|
|
29409
29960
|
function openCreateCronModal(agentSlug) {
|
|
29410
29961
|
_cronAgentContext = agentSlug || '';
|
|
29411
29962
|
editingCronJob = null;
|
|
@@ -17,7 +17,19 @@ export declare function todayISO(): string;
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function logToDailyNote(line: string): void;
|
|
19
19
|
/**
|
|
20
|
-
* Parse cron job definitions from
|
|
20
|
+
* Parse cron job definitions from two sources, merged:
|
|
21
|
+
* 1. The legacy `vault/00-System/CRON.md` frontmatter (fat cron format
|
|
22
|
+
* — its own prompt, allowedTools, allowedMcpServers, etc.).
|
|
23
|
+
* 2. The 1.18.129 schedule registry at `~/.clementine/schedules.json`,
|
|
24
|
+
* which holds thin {skill → schedule} bindings. Each entry is
|
|
25
|
+
* synthesized into a CronJobDefinition where the runtime auto-pins
|
|
26
|
+
* the named skill — the skill body becomes the prompt at fire-time
|
|
27
|
+
* via the existing buildSkillContext pipeline. Anthropic-pure.
|
|
28
|
+
*
|
|
29
|
+
* On a name collision (CRON.md has a job whose name matches a scheduled
|
|
30
|
+
* skill) the **scheduled-skill wins**, because that's the new canonical
|
|
31
|
+
* format and the user has explicitly opted into it for that name.
|
|
32
|
+
*
|
|
21
33
|
* Used by both the in-process CronScheduler and the standalone CLI runner.
|
|
22
34
|
*/
|
|
23
35
|
export declare function parseCronJobs(): CronJobDefinition[];
|
|
@@ -14,6 +14,7 @@ import matter from 'gray-matter';
|
|
|
14
14
|
import pino from 'pino';
|
|
15
15
|
import { CRON_FILE, WORKFLOWS_DIR, AGENTS_DIR, DAILY_NOTES_DIR, BASE_DIR, DISCORD_OWNER_ID, GOALS_DIR, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH, TIMEZONE, } from '../config.js';
|
|
16
16
|
import { listAllGoals, findGoalPath, readGoalById } from '../tools/shared.js';
|
|
17
|
+
import { listSchedules } from '../agent/schedule-registry.js';
|
|
17
18
|
import { scanner } from '../security/scanner.js';
|
|
18
19
|
import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
|
|
19
20
|
import { SelfImproveLoop } from '../agent/self-improve.js';
|
|
@@ -161,30 +162,93 @@ function parseJobYaml(job) {
|
|
|
161
162
|
};
|
|
162
163
|
}
|
|
163
164
|
/**
|
|
164
|
-
* Parse cron job definitions from
|
|
165
|
+
* Parse cron job definitions from two sources, merged:
|
|
166
|
+
* 1. The legacy `vault/00-System/CRON.md` frontmatter (fat cron format
|
|
167
|
+
* — its own prompt, allowedTools, allowedMcpServers, etc.).
|
|
168
|
+
* 2. The 1.18.129 schedule registry at `~/.clementine/schedules.json`,
|
|
169
|
+
* which holds thin {skill → schedule} bindings. Each entry is
|
|
170
|
+
* synthesized into a CronJobDefinition where the runtime auto-pins
|
|
171
|
+
* the named skill — the skill body becomes the prompt at fire-time
|
|
172
|
+
* via the existing buildSkillContext pipeline. Anthropic-pure.
|
|
173
|
+
*
|
|
174
|
+
* On a name collision (CRON.md has a job whose name matches a scheduled
|
|
175
|
+
* skill) the **scheduled-skill wins**, because that's the new canonical
|
|
176
|
+
* format and the user has explicitly opted into it for that name.
|
|
177
|
+
*
|
|
165
178
|
* Used by both the in-process CronScheduler and the standalone CLI runner.
|
|
166
179
|
*/
|
|
167
180
|
export function parseCronJobs() {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
const fromCronMd = [];
|
|
182
|
+
if (existsSync(CRON_FILE)) {
|
|
183
|
+
try {
|
|
184
|
+
const parsed = matter(readFileSync(CRON_FILE, 'utf-8'));
|
|
185
|
+
const jobDefs = (parsed.data.jobs ?? []);
|
|
186
|
+
for (const job of jobDefs) {
|
|
187
|
+
const def = parseJobYaml(job);
|
|
188
|
+
if (def)
|
|
189
|
+
fromCronMd.push(def);
|
|
190
|
+
else
|
|
191
|
+
logger.warn({ job }, 'Skipping malformed cron job');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
logger.error({ err }, 'CRON.md YAML parse error — keeping schedule-registry jobs only.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 1.18.129 — schedule registry → CronJobDefinition[]. The scheduler
|
|
199
|
+
// doesn't care about the source format; each entry surfaces as a
|
|
200
|
+
// self-pinning cron whose only "prompt" is "[skill body]" — the
|
|
201
|
+
// runtime's buildSkillContext loads the actual procedure.
|
|
202
|
+
const fromRegistry = [];
|
|
171
203
|
try {
|
|
172
|
-
|
|
204
|
+
const registry = parseScheduledSkillJobs();
|
|
205
|
+
fromRegistry.push(...registry);
|
|
173
206
|
}
|
|
174
207
|
catch (err) {
|
|
175
|
-
logger.error({ err }, '
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
for (const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
208
|
+
logger.error({ err }, 'schedules.json parse error — falling back to CRON.md only');
|
|
209
|
+
}
|
|
210
|
+
// Dedup by name with scheduled-skill winning. Build a Map keyed on
|
|
211
|
+
// job name; insert CRON.md first, then registry to overwrite collisions.
|
|
212
|
+
const byName = new Map();
|
|
213
|
+
for (const j of fromCronMd)
|
|
214
|
+
byName.set(j.name, j);
|
|
215
|
+
for (const j of fromRegistry)
|
|
216
|
+
byName.set(j.name, j);
|
|
217
|
+
return [...byName.values()];
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Read the schedule registry and project each entry into a
|
|
221
|
+
* CronJobDefinition that runs the named skill on the given schedule.
|
|
222
|
+
* Skill body / tools / MCP servers all flow through buildSkillContext
|
|
223
|
+
* at fire-time — this function only concerns itself with the binding.
|
|
224
|
+
*/
|
|
225
|
+
function parseScheduledSkillJobs() {
|
|
226
|
+
const entries = listSchedules();
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
if (!e.skillName || !e.schedule)
|
|
230
|
+
continue;
|
|
231
|
+
out.push({
|
|
232
|
+
name: e.skillName,
|
|
233
|
+
schedule: e.schedule,
|
|
234
|
+
// Empty prompt — buildSkillContext injects the skill body. The
|
|
235
|
+
// runtime treats an empty prompt + a single pinned skill as
|
|
236
|
+
// "this skill IS the task." How-to-respond directive still applies.
|
|
237
|
+
prompt: '',
|
|
238
|
+
tier: 1,
|
|
239
|
+
enabled: e.enabled !== false,
|
|
240
|
+
skills: [e.skillName],
|
|
241
|
+
agentSlug: e.agentSlug ?? undefined,
|
|
242
|
+
// Predictable mode is the right default for scheduled skills:
|
|
243
|
+
// skip MEMORY.md auto-injection / team comms / runtime auto-match.
|
|
244
|
+
// The skill is the contract; nothing else fires.
|
|
245
|
+
predictable: true,
|
|
246
|
+
// Source marker so the dashboard can render a SKILL badge vs the
|
|
247
|
+
// legacy CRON.md badge. Not used by the runtime.
|
|
248
|
+
source: 'scheduled-skill',
|
|
249
|
+
});
|
|
186
250
|
}
|
|
187
|
-
return
|
|
251
|
+
return out;
|
|
188
252
|
}
|
|
189
253
|
/**
|
|
190
254
|
* Parse cron jobs from agent-scoped CRON.md files.
|
package/dist/types.d.ts
CHANGED
|
@@ -550,6 +550,14 @@ export interface CronJobDefinition {
|
|
|
550
550
|
* Existing tricks (no field set) keep current behavior — backward compat.
|
|
551
551
|
*/
|
|
552
552
|
predictable?: boolean;
|
|
553
|
+
/** 1.18.129 — where this cron job came from. 'cron-md' is the legacy
|
|
554
|
+
* fat-cron format in vault/00-System/CRON.md (or per-agent CRON.md).
|
|
555
|
+
* 'scheduled-skill' is the Anthropic-pure registry — a thin entry in
|
|
556
|
+
* ~/.clementine/schedules.json that auto-pins one named skill. The
|
|
557
|
+
* runtime treats both identically; the field exists so the dashboard
|
|
558
|
+
* can render a SKILL vs LEGACY CRON badge per row. Undefined =
|
|
559
|
+
* legacy CRON.md (older parsers don't stamp this). */
|
|
560
|
+
source?: 'cron-md' | 'scheduled-skill';
|
|
553
561
|
}
|
|
554
562
|
export type LongTaskRisk = 'normal' | 'long' | 'huge' | 'unsafe';
|
|
555
563
|
export type LongTaskRoute = 'standard' | 'checkpointed' | 'opus_1m' | 'sonnet_1m' | 'split_required';
|