openwriter 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,12 +9,13 @@ import { randomUUID } from 'crypto';
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
- import { DATA_DIR, ensureDataDir, resolveDocPath } from './helpers.js';
12
+ import { getDataDir, ensureDataDir, resolveDocPath } from './helpers.js';
13
13
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, } from './state.js';
14
14
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
15
15
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
16
- import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, renameWorkspace, renameContainer } from './workspaces.js';
16
+ import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, removeContainer, renameWorkspace, renameContainer } from './workspaces.js';
17
17
  import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
18
+ import { findDocNode } from './workspace-tree.js';
18
19
  import { importGoogleDoc } from './gdoc-import.js';
19
20
  import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
20
21
  import matter from 'gray-matter';
@@ -23,6 +24,19 @@ import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
23
24
  import { markdownToTiptap } from './markdown.js';
24
25
  import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
25
26
  import { broadcastMarksChanged } from './ws.js';
27
+ /** Map a content type string to its frontmatter metadata object. */
28
+ function resolveTypeMeta(type) {
29
+ switch (type) {
30
+ case 'tweet': return { tweetContext: { mode: 'tweet' } };
31
+ case 'reply': return { tweetContext: { mode: 'reply' } };
32
+ case 'quote': return { tweetContext: { mode: 'quote' } };
33
+ case 'article': return { articleContext: { active: true } };
34
+ case 'linkedin': return { linkedinContext: { active: true } };
35
+ case 'newsletter': return { newsletterContext: { active: true } };
36
+ case 'blog': return { blogContext: { active: true } };
37
+ default: return undefined;
38
+ }
39
+ }
26
40
  /** Check if a document is in tweet compose mode (has tweetContext metadata). */
27
41
  function isTweetDoc(filename) {
28
42
  if (!filename || filename === getActiveFilename()) {
@@ -171,15 +185,24 @@ export const TOOL_REGISTRY = [
171
185
  },
172
186
  {
173
187
  name: 'create_document',
174
- description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called — set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent content). If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
188
+ description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called — set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent content). If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist). Use content_type to create typed documents (tweet, article, linkedin, etc.) with the correct metadata pre-set.',
175
189
  schema: {
176
190
  title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
177
191
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
178
192
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
179
193
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
180
194
  empty: z.boolean().optional().describe('If true, skip the writing spinner and switch to the doc immediately. No need to call populate_document. Use for template docs (tweets, articles) that start empty.'),
181
- },
182
- handler: async ({ title, path, workspace, container, empty }) => {
195
+ content_type: z.string().optional().describe('Content type: tweet, reply, quote, article, linkedin, newsletter, or blog. Sets metadata so the doc is recognized as that type. For reply/quote, use set_metadata after creation to set the target tweet URL.'),
196
+ },
197
+ handler: async ({ title, path, workspace, container, empty, content_type }) => {
198
+ // Default title from content_type if not provided
199
+ if (!title && content_type) {
200
+ const typeDefaults = {
201
+ tweet: 'Tweet', reply: 'Reply', quote: 'Quote Tweet', article: 'Article',
202
+ linkedin: 'LinkedIn Post', newsletter: 'Newsletter', blog: 'Blog Post',
203
+ };
204
+ title = typeDefaults[content_type];
205
+ }
183
206
  // Resolve workspace/container up front so spinner renders in the right place
184
207
  let wsTarget;
185
208
  if (workspace) {
@@ -202,6 +225,13 @@ export const TOOL_REGISTRY = [
202
225
  // Immediate switch — no spinner, no populate_document needed
203
226
  setAgentLock();
204
227
  const result = createDocument(title, undefined, path);
228
+ // Apply type-specific metadata
229
+ if (content_type) {
230
+ const typeMeta = resolveTypeMeta(content_type);
231
+ if (typeMeta) {
232
+ setMetadata(typeMeta);
233
+ }
234
+ }
205
235
  let wsInfo = '';
206
236
  if (wsTarget) {
207
237
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
@@ -211,17 +241,18 @@ export const TOOL_REGISTRY = [
211
241
  save();
212
242
  broadcastDocumentsChanged();
213
243
  broadcastWorkspacesChanged();
214
- broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
244
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
215
245
  return {
216
246
  content: [{
217
247
  type: 'text',
218
- text: `Created "${result.title}" [${newDocId}]${wsInfo} — ready.`,
248
+ text: `Created "${result.title}" [${newDocId}]${wsInfo}${content_type ? ` (${content_type})` : ''} — ready.`,
219
249
  }],
220
250
  };
221
251
  }
222
252
  // Two-step flow: create file on disk WITHOUT switching the user's view.
223
253
  // The spinner persists in the sidebar until populate_document is called.
224
- const result = createDocumentFile(title, path);
254
+ const typeMeta = content_type ? resolveTypeMeta(content_type) : undefined;
255
+ const result = createDocumentFile(title, path, typeMeta);
225
256
  let wsInfo = '';
226
257
  if (wsTarget) {
227
258
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
@@ -507,27 +538,11 @@ export const TOOL_REGISTRY = [
507
538
  description: 'Get progressive disclosure context for a document in a workspace: workspace-level context (characters, settings, rules) and tags. Use before writing to understand context.',
508
539
  schema: {
509
540
  workspaceFile: z.string().describe('Workspace manifest filename'),
510
- docFile: z.string().describe('Document filename within the workspace'),
511
- },
512
- handler: async ({ workspaceFile, docFile }) => {
513
- return { content: [{ type: 'text', text: JSON.stringify(getItemContext(workspaceFile, docFile), null, 2) }] };
541
+ docId: z.string().describe('Document docId (8-char hex from list_documents)'),
514
542
  },
515
- },
516
- {
517
- name: 'add_doc',
518
- description: 'Add a document to a workspace. Optionally place it inside a container.',
519
- schema: {
520
- workspaceFile: z.string().describe('Workspace manifest filename'),
521
- docFile: z.string().describe('Document filename to add (e.g. "Chapter 1.md")'),
522
- containerId: z.string().optional().describe('Container ID to add into (null = root level)'),
523
- title: z.string().optional().describe('Display title for the doc'),
524
- },
525
- handler: async ({ workspaceFile, docFile, containerId, title }) => {
526
- addDoc(workspaceFile, containerId ?? null, docFile, title || docFile.replace(/\.md$/, ''));
527
- broadcastWorkspacesChanged();
528
- return {
529
- content: [{ type: 'text', text: `Added "${docFile}" to workspace${containerId ? ` in container ${containerId}` : ''}` }],
530
- };
543
+ handler: async ({ workspaceFile, docId }) => {
544
+ const filename = resolveDocId(docId);
545
+ return { content: [{ type: 'text', text: JSON.stringify(getItemContext(workspaceFile, filename), null, 2) }] };
531
546
  },
532
547
  },
533
548
  {
@@ -561,45 +576,70 @@ export const TOOL_REGISTRY = [
561
576
  return { content: [{ type: 'text', text: `Created container "${name}" (id:${result.containerId})` }] };
562
577
  },
563
578
  },
579
+ {
580
+ name: 'delete_container',
581
+ description: 'Delete a container from a workspace. Any docs inside are removed from the workspace (files are NOT deleted from disk).',
582
+ schema: {
583
+ workspaceFile: z.string().describe('Workspace manifest filename'),
584
+ containerId: z.string().describe('Container ID to delete'),
585
+ },
586
+ handler: async ({ workspaceFile, containerId }) => {
587
+ removeContainer(workspaceFile, containerId);
588
+ broadcastWorkspacesChanged();
589
+ return { content: [{ type: 'text', text: `Deleted container ${containerId}` }] };
590
+ },
591
+ },
564
592
  {
565
593
  name: 'tag_doc',
566
594
  description: 'Add a tag to a document. Tags are stored in the document\'s frontmatter — they travel with the file. A doc can have multiple tags.',
567
595
  schema: {
568
- docFile: z.string().describe('Document filename (e.g. "Chapter 1.md")'),
596
+ docId: z.string().describe('Document docId (8-char hex from list_documents)'),
569
597
  tag: z.string().describe('Tag name to add'),
570
598
  },
571
- handler: async ({ docFile, tag }) => {
572
- addDocTag(docFile, tag);
599
+ handler: async ({ docId, tag }) => {
600
+ const filename = resolveDocId(docId);
601
+ addDocTag(filename, tag);
573
602
  broadcastDocumentsChanged();
574
- return { content: [{ type: 'text', text: `Tagged "${docFile}" with [${tag}]` }] };
603
+ return { content: [{ type: 'text', text: `Tagged "${filename}" with [${tag}]` }] };
575
604
  },
576
605
  },
577
606
  {
578
607
  name: 'untag_doc',
579
608
  description: 'Remove a tag from a document.',
580
609
  schema: {
581
- docFile: z.string().describe('Document filename'),
610
+ docId: z.string().describe('Document docId (8-char hex from list_documents)'),
582
611
  tag: z.string().describe('Tag name to remove'),
583
612
  },
584
- handler: async ({ docFile, tag }) => {
585
- removeDocTag(docFile, tag);
613
+ handler: async ({ docId, tag }) => {
614
+ const filename = resolveDocId(docId);
615
+ removeDocTag(filename, tag);
586
616
  broadcastDocumentsChanged();
587
- return { content: [{ type: 'text', text: `Removed tag [${tag}] from "${docFile}"` }] };
617
+ return { content: [{ type: 'text', text: `Removed tag [${tag}] from "${filename}"` }] };
588
618
  },
589
619
  },
590
620
  {
591
621
  name: 'move_doc',
592
- description: 'Move a document to a different container within the same workspace, or to root level.',
622
+ description: 'Add a document to a workspace, or move it within the workspace. If the doc is not yet in the workspace it will be added; if it is already present it will be moved to the target container.',
593
623
  schema: {
594
624
  workspaceFile: z.string().describe('Workspace manifest filename'),
595
- docFile: z.string().describe('Document filename to move'),
625
+ docId: z.string().describe('Document docId (8-char hex from list_documents)'),
596
626
  targetContainerId: z.string().optional().describe('Target container ID (omit for root level)'),
597
627
  afterFile: z.string().optional().describe('Place after this file (omit for beginning)'),
598
628
  },
599
- handler: async ({ workspaceFile, docFile, targetContainerId, afterFile }) => {
600
- moveDoc(workspaceFile, docFile, targetContainerId ?? null, afterFile ?? null);
629
+ handler: async ({ workspaceFile, docId, targetContainerId, afterFile }) => {
630
+ const filename = resolveDocId(docId);
631
+ const ws = getWorkspace(workspaceFile);
632
+ const existing = findDocNode(ws.root, filename);
633
+ if (existing) {
634
+ moveDoc(workspaceFile, filename, targetContainerId ?? null, afterFile ?? null);
635
+ }
636
+ else {
637
+ const title = getDocTitle(filename);
638
+ addDoc(workspaceFile, targetContainerId ?? null, filename, title, afterFile ?? null);
639
+ }
601
640
  broadcastWorkspacesChanged();
602
- return { content: [{ type: 'text', text: `Moved "${docFile}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
641
+ const action = existing ? 'Moved' : 'Added';
642
+ return { content: [{ type: 'text', text: `${action} "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
603
643
  },
604
644
  },
605
645
  {
@@ -726,7 +766,7 @@ export const TOOL_REGISTRY = [
726
766
  }
727
767
  // Save to ~/.openwriter/_images/
728
768
  ensureDataDir();
729
- const imagesDir = join(DATA_DIR, '_images');
769
+ const imagesDir = join(getDataDir(), '_images');
730
770
  if (!existsSync(imagesDir))
731
771
  mkdirSync(imagesDir, { recursive: true });
732
772
  const filename = `${randomUUID().slice(0, 8)}.png`;
@@ -746,8 +786,10 @@ export const TOOL_REGISTRY = [
746
786
  }],
747
787
  };
748
788
  }
749
- // Use pre-await metadata snapshot to build the update (not live state)
750
- const articleContext = preAwaitMeta.articleContext || {};
789
+ // Use LIVE metadata for coverImages (not stale pre-await snapshot)
790
+ // so concurrent generate_image calls don't overwrite each other's results
791
+ const liveMeta = getMetadata();
792
+ const articleContext = liveMeta.articleContext || {};
751
793
  let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
752
794
  // Seed with current coverImage if array is empty (first carousel entry)
753
795
  if (existing.length === 0 && articleContext.coverImage) {
@@ -801,7 +843,7 @@ export const TOOL_REGISTRY = [
801
843
  }
802
844
  // Save to ~/.openwriter/_images/
803
845
  ensureDataDir();
804
- const imagesDir = join(DATA_DIR, '_images');
846
+ const imagesDir = join(getDataDir(), '_images');
805
847
  if (!existsSync(imagesDir))
806
848
  mkdirSync(imagesDir, { recursive: true });
807
849
  const imgFilename = `${randomUUID().slice(0, 8)}.png`;
@@ -950,21 +992,59 @@ export const TOOL_REGISTRY = [
950
992
  },
951
993
  },
952
994
  ];
953
- /** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
995
+ /** Live MCP server instance used to register plugin tools dynamically. */
996
+ let mcpServerInstance = null;
997
+ /** Convert a JSON Schema properties object to a Zod shape for MCP tool registration. */
998
+ function jsonSchemaToZodShape(inputSchema) {
999
+ const properties = (inputSchema.properties || {});
1000
+ const required = new Set((inputSchema.required || []));
1001
+ const shape = {};
1002
+ for (const [key, prop] of Object.entries(properties)) {
1003
+ let field;
1004
+ switch (prop.type) {
1005
+ case 'number':
1006
+ field = z.number();
1007
+ break;
1008
+ case 'boolean':
1009
+ field = z.boolean();
1010
+ break;
1011
+ default:
1012
+ field = z.string();
1013
+ break;
1014
+ }
1015
+ if (prop.description)
1016
+ field = field.describe(prop.description);
1017
+ if (!required.has(key))
1018
+ field = field.optional();
1019
+ shape[key] = field;
1020
+ }
1021
+ return shape;
1022
+ }
1023
+ /** Register MCP tools from plugins. Dynamically adds to the live MCP session. */
954
1024
  export function registerPluginTools(tools) {
955
1025
  for (const tool of tools) {
956
- TOOL_REGISTRY.push({
1026
+ const zodShape = jsonSchemaToZodShape(tool.inputSchema);
1027
+ const toolDef = {
957
1028
  name: tool.name,
958
1029
  description: tool.description,
959
- schema: {},
1030
+ schema: zodShape,
960
1031
  handler: async (args) => {
961
1032
  const result = await tool.handler(args);
962
1033
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
963
1034
  },
964
- });
1035
+ };
1036
+ TOOL_REGISTRY.push(toolDef);
1037
+ // Register on live MCP server so existing sessions see it immediately
1038
+ if (mcpServerInstance) {
1039
+ mcpServerInstance.tool(tool.name, tool.description, zodShape, toolDef.handler);
1040
+ }
1041
+ }
1042
+ // Notify connected clients that the tool list changed
1043
+ if (mcpServerInstance) {
1044
+ mcpServerInstance.server.sendToolListChanged().catch(() => { });
965
1045
  }
966
1046
  }
967
- /** Remove MCP tools by name. Existing MCP stdio sessions won't see removal until reconnect. */
1047
+ /** Remove MCP tools by name. Notifies connected clients of the change. */
968
1048
  export function removePluginTools(names) {
969
1049
  const nameSet = new Set(names);
970
1050
  for (let i = TOOL_REGISTRY.length - 1; i >= 0; i--) {
@@ -972,6 +1052,9 @@ export function removePluginTools(names) {
972
1052
  TOOL_REGISTRY.splice(i, 1);
973
1053
  }
974
1054
  }
1055
+ if (mcpServerInstance) {
1056
+ mcpServerInstance.server.sendToolListChanged().catch(() => { });
1057
+ }
975
1058
  }
976
1059
  export async function startMcpServer() {
977
1060
  const server = new McpServer({
@@ -981,6 +1064,7 @@ export async function startMcpServer() {
981
1064
  for (const tool of TOOL_REGISTRY) {
982
1065
  server.tool(tool.name, tool.description, tool.schema, tool.handler);
983
1066
  }
1067
+ mcpServerInstance = server;
984
1068
  const transport = new StdioServerTransport();
985
1069
  await server.connect(transport);
986
1070
  }
@@ -5,7 +5,7 @@
5
5
  import { Router as createRouter } from 'express';
6
6
  import { discoverPlugins, loadPluginModule } from './plugin-discovery.js';
7
7
  import { registerPluginTools, removePluginTools } from './mcp.js';
8
- import { readConfig, saveConfig } from './helpers.js';
8
+ import { readConfig, saveConfig, getDataDir } from './helpers.js';
9
9
  import { broadcastPluginsChanged } from './ws.js';
10
10
  export class PluginManager {
11
11
  app;
@@ -55,7 +55,7 @@ export class PluginManager {
55
55
  // Register routes via togglable middleware
56
56
  if (plugin.registerRoutes) {
57
57
  const router = createRouter();
58
- await plugin.registerRoutes({ app: router, config: resolvedConfig });
58
+ await plugin.registerRoutes({ app: router, config: resolvedConfig, dataDir: getDataDir() });
59
59
  managed.router = router;
60
60
  // Wrap in middleware that skips when disabled
61
61
  managed.middleware = (req, res, next) => {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Scheduler routes — proxy all requests to the platform API.
3
+ */
4
+ import { Router } from 'express';
5
+ import { platformFetch, isAuthenticated } from './connections.js';
6
+ export function createSchedulerRouter() {
7
+ const router = Router();
8
+ function proxy(path, method = 'GET') {
9
+ return async (req, res) => {
10
+ try {
11
+ if (!isAuthenticated()) {
12
+ res.json({ error: 'Not authenticated' });
13
+ return;
14
+ }
15
+ const options = { method };
16
+ if (method !== 'GET' && method !== 'DELETE') {
17
+ options.body = JSON.stringify(req.body);
18
+ }
19
+ const upstream = await platformFetch(path, options);
20
+ const data = await upstream.json();
21
+ if (!upstream.ok) {
22
+ res.status(upstream.status).json(data);
23
+ return;
24
+ }
25
+ res.json(data);
26
+ }
27
+ catch (err) {
28
+ res.status(500).json({ error: err.message });
29
+ }
30
+ };
31
+ }
32
+ // Slots
33
+ router.get('/api/scheduler/slots', proxy('/scheduler/slots'));
34
+ router.post('/api/scheduler/slots', proxy('/scheduler/slots', 'POST'));
35
+ router.patch('/api/scheduler/slots/:id', async (req, res) => {
36
+ try {
37
+ if (!isAuthenticated()) {
38
+ res.json({ error: 'Not authenticated' });
39
+ return;
40
+ }
41
+ const upstream = await platformFetch(`/scheduler/slots/${req.params.id}`, {
42
+ method: 'PATCH',
43
+ body: JSON.stringify(req.body),
44
+ });
45
+ const data = await upstream.json();
46
+ if (!upstream.ok) {
47
+ res.status(upstream.status).json(data);
48
+ return;
49
+ }
50
+ res.json(data);
51
+ }
52
+ catch (err) {
53
+ res.status(500).json({ error: err.message });
54
+ }
55
+ });
56
+ router.delete('/api/scheduler/slots/:id', async (req, res) => {
57
+ try {
58
+ if (!isAuthenticated()) {
59
+ res.json({ error: 'Not authenticated' });
60
+ return;
61
+ }
62
+ const upstream = await platformFetch(`/scheduler/slots/${req.params.id}`, { method: 'DELETE' });
63
+ const data = await upstream.json();
64
+ if (!upstream.ok) {
65
+ res.status(upstream.status).json(data);
66
+ return;
67
+ }
68
+ res.json(data);
69
+ }
70
+ catch (err) {
71
+ res.status(500).json({ error: err.message });
72
+ }
73
+ });
74
+ // Queue
75
+ router.get('/api/scheduler/queue', proxy('/scheduler/queue'));
76
+ router.post('/api/scheduler/queue', proxy('/scheduler/queue', 'POST'));
77
+ router.patch('/api/scheduler/queue/:id', async (req, res) => {
78
+ try {
79
+ if (!isAuthenticated()) {
80
+ res.json({ error: 'Not authenticated' });
81
+ return;
82
+ }
83
+ const upstream = await platformFetch(`/scheduler/queue/${req.params.id}`, {
84
+ method: 'PATCH',
85
+ body: JSON.stringify(req.body),
86
+ });
87
+ const data = await upstream.json();
88
+ if (!upstream.ok) {
89
+ res.status(upstream.status).json(data);
90
+ return;
91
+ }
92
+ res.json(data);
93
+ }
94
+ catch (err) {
95
+ res.status(500).json({ error: err.message });
96
+ }
97
+ });
98
+ router.delete('/api/scheduler/queue/:id', async (req, res) => {
99
+ try {
100
+ if (!isAuthenticated()) {
101
+ res.json({ error: 'Not authenticated' });
102
+ return;
103
+ }
104
+ const upstream = await platformFetch(`/scheduler/queue/${req.params.id}`, { method: 'DELETE' });
105
+ const data = await upstream.json();
106
+ if (!upstream.ok) {
107
+ res.status(upstream.status).json(data);
108
+ return;
109
+ }
110
+ res.json(data);
111
+ }
112
+ catch (err) {
113
+ res.status(500).json({ error: err.message });
114
+ }
115
+ });
116
+ // History
117
+ router.get('/api/scheduler/history', proxy('/scheduler/history'));
118
+ // Available connections for scheduler
119
+ router.get('/api/scheduler/connections', proxy('/scheduler/connections'));
120
+ return router;
121
+ }