loop-task 2.1.11 → 2.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/app/App.js +3 -1
  2. package/dist/cli.js +88 -2
  3. package/dist/client/commands.js +8 -1
  4. package/dist/client/project-commands.js +7 -2
  5. package/dist/core/context/template.js +1 -1
  6. package/dist/core/context/validate-context.js +19 -0
  7. package/dist/core/loop/chain-executor.js +50 -17
  8. package/dist/core/loop/loop-controller.js +18 -0
  9. package/dist/core/loop/loop-runner.js +8 -0
  10. package/dist/core/loop/run-executor.js +1 -1
  11. package/dist/core/scheduling/index.js +4 -0
  12. package/dist/daemon/http/openapi.js +31 -4
  13. package/dist/daemon/http/route-loops.js +106 -0
  14. package/dist/daemon/http/route-misc.js +16 -0
  15. package/dist/daemon/http/route-projects.js +2 -2
  16. package/dist/daemon/http/route-tasks.js +75 -0
  17. package/dist/daemon/http/routes.js +3 -2
  18. package/dist/daemon/http/server.js +20 -2
  19. package/dist/daemon/index.js +48 -7
  20. package/dist/daemon/managers/loop-entry.js +2 -0
  21. package/dist/daemon/managers/loop-manager.js +4 -4
  22. package/dist/daemon/managers/loop-options.js +1 -0
  23. package/dist/daemon/managers/loop-serialization.js +1 -0
  24. package/dist/daemon/managers/project-manager.js +16 -2
  25. package/dist/daemon/mcp/index.js +2 -0
  26. package/dist/daemon/mcp/openapi-sync.js +50 -0
  27. package/dist/daemon/mcp/server.js +162 -0
  28. package/dist/daemon/mcp/tools.js +368 -0
  29. package/dist/daemon/server/handlers/index.js +8 -1
  30. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  31. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  32. package/dist/daemon/server/index.js +3 -1
  33. package/dist/daemon/settings-manager.js +46 -0
  34. package/dist/duration.js +5 -0
  35. package/dist/entities/loops/filters.js +2 -0
  36. package/dist/entities/tasks/filters.js +1 -1
  37. package/dist/features/commands/commands.js +4 -1
  38. package/dist/features/commands/useCommandHandlers.js +32 -0
  39. package/dist/features/commands/useGlobalShortcuts.js +0 -1
  40. package/dist/features/forms/FormRouter.js +2 -0
  41. package/dist/features/overlays/ExportModal.js +1 -1
  42. package/dist/loop-config.js +8 -4
  43. package/dist/shared/clipboard.js +2 -2
  44. package/dist/shared/config/paths.js +3 -0
  45. package/dist/shared/container/index.js +2 -0
  46. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  47. package/dist/shared/hooks/useUndoRedo.js +1 -1
  48. package/dist/shared/i18n/en.json +31 -4
  49. package/dist/shared/services/project-service.js +4 -4
  50. package/dist/shared/services/settings-service.js +49 -0
  51. package/dist/shared/services/types.js +1 -0
  52. package/dist/shared/ui/FocusableInput.js +1 -1
  53. package/dist/shared/ui/SelectModal.js +1 -1
  54. package/dist/shared/ui/format.js +2 -0
  55. package/dist/shared/utils/syntax.js +3 -3
  56. package/dist/widgets/header/Header.js +15 -2
  57. package/dist/widgets/left-panel/Navigator.js +3 -2
  58. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  59. package/dist/widgets/log-modal/LogModal.js +1 -1
  60. package/dist/widgets/loop-form/CreateForm.js +9 -2
  61. package/dist/widgets/loop-form/WizardForm.js +12 -2
  62. package/dist/widgets/loop-form/useCreateSteps.js +30 -2
  63. package/dist/widgets/loop-form/useHandleComplete.js +23 -3
  64. package/dist/widgets/project-form/ProjectForm.js +11 -2
  65. package/dist/widgets/right-panel/Inspector.js +1 -1
  66. package/dist/widgets/right-panel/RightPanel.js +1 -1
  67. package/dist/widgets/task-form/TaskForm.js +69 -4
  68. package/package.json +4 -2
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
3
3
  import { tail } from "../../shared/tail.js";
4
4
  import { buildLoopOptions } from "../../loop-config.js";
5
+ import { validateContext } from "../../core/context/validate-context.js";
5
6
  import { sendOk, sendError, sendNotFound, parseQuery, readBody } from "./helpers.js";
6
7
  export function registerLoopRoutes(manager, routes, r) {
7
8
  r("GET", "/api/loops", (_req, res) => {
@@ -18,6 +19,15 @@ export function registerLoopRoutes(manager, routes, r) {
18
19
  r("POST", "/api/loops", async (req, res) => {
19
20
  try {
20
21
  const body = await readBody(req);
22
+ let context;
23
+ if (body.context !== undefined) {
24
+ const result = validateContext(body.context);
25
+ if (!result.valid) {
26
+ sendError(res, 400, result.error);
27
+ return;
28
+ }
29
+ context = result.context;
30
+ }
21
31
  const intervalHuman = body.intervalHuman ?? "5m";
22
32
  const { options } = buildLoopOptions(intervalHuman, {
23
33
  command: body.command,
@@ -30,6 +40,7 @@ export function registerLoopRoutes(manager, routes, r) {
30
40
  description: body.description,
31
41
  projectId: body.projectId,
32
42
  offset: body.offset,
43
+ context,
33
44
  });
34
45
  const id = manager.start(options, intervalHuman);
35
46
  sendOk(res, { id }, 201);
@@ -41,6 +52,15 @@ export function registerLoopRoutes(manager, routes, r) {
41
52
  r("PATCH", "/api/loops/:id", async (req, res, params) => {
42
53
  try {
43
54
  const body = await readBody(req);
55
+ let context;
56
+ if (body.context !== undefined) {
57
+ const result = validateContext(body.context);
58
+ if (!result.valid) {
59
+ sendError(res, 400, result.error);
60
+ return;
61
+ }
62
+ context = result.context;
63
+ }
44
64
  const intervalHuman = body.intervalHuman ?? "5m";
45
65
  const { options } = buildLoopOptions(intervalHuman, {
46
66
  command: body.command,
@@ -53,6 +73,7 @@ export function registerLoopRoutes(manager, routes, r) {
53
73
  description: body.description,
54
74
  projectId: body.projectId,
55
75
  offset: body.offset,
76
+ context,
56
77
  });
57
78
  const ok = await manager.update(params.id, options, intervalHuman);
58
79
  if (!ok) {
@@ -87,6 +108,22 @@ export function registerLoopRoutes(manager, routes, r) {
87
108
  }
88
109
  sendOk(res);
89
110
  });
111
+ r("POST", "/api/loops/:id/play", (_req, res, params) => {
112
+ if (manager.isMaxRunsBlocked(params.id)) {
113
+ sendError(res, 409, "Max runs reached");
114
+ return;
115
+ }
116
+ if (manager.isRunning(params.id)) {
117
+ sendError(res, 409, "Loop is already running");
118
+ return;
119
+ }
120
+ if (!manager.playLoop(params.id)) {
121
+ sendNotFound(res, params.id);
122
+ return;
123
+ }
124
+ const meta = manager.status(params.id);
125
+ sendOk(res, meta);
126
+ });
90
127
  r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
91
128
  if (manager.isMaxRunsBlocked(params.id)) {
92
129
  sendError(res, 400, "Max runs reached");
@@ -113,6 +150,75 @@ export function registerLoopRoutes(manager, routes, r) {
113
150
  const count = manager.stopAllLoops();
114
151
  sendOk(res, { count });
115
152
  });
153
+ // --- Run History ---
154
+ r("GET", "/api/loops/:id/runs", (_req, res, params) => {
155
+ const meta = manager.status(params.id);
156
+ if (!meta) {
157
+ sendNotFound(res, params.id);
158
+ return;
159
+ }
160
+ const query = parseQuery(_req.url);
161
+ const fromStr = query.get("from");
162
+ const toStr = query.get("to");
163
+ let runs = meta.runHistory;
164
+ if (fromStr) {
165
+ const fromMs = new Date(fromStr).getTime();
166
+ if (!Number.isNaN(fromMs)) {
167
+ runs = runs.filter((r) => new Date(r.startedAt).getTime() >= fromMs);
168
+ }
169
+ }
170
+ if (toStr) {
171
+ const toMs = new Date(toStr).getTime();
172
+ if (!Number.isNaN(toMs)) {
173
+ runs = runs.filter((r) => new Date(r.startedAt).getTime() <= toMs);
174
+ }
175
+ }
176
+ sendOk(res, runs);
177
+ });
178
+ // --- Date-Filtered Logs ---
179
+ r("GET", "/api/loops/:id/logs/date", (_req, res, params) => {
180
+ const meta = manager.status(params.id);
181
+ if (!meta) {
182
+ sendNotFound(res, params.id);
183
+ return;
184
+ }
185
+ const query = parseQuery(_req.url);
186
+ const fromStr = query.get("from");
187
+ const toStr = query.get("to");
188
+ if (!fromStr || !toStr) {
189
+ sendError(res, 400, "Both 'from' and 'to' query parameters are required (ISO 8601)");
190
+ return;
191
+ }
192
+ const fromMs = new Date(fromStr).getTime();
193
+ const toMs = new Date(toStr).getTime();
194
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
195
+ sendError(res, 400, "Invalid date format for 'from' or 'to' (use ISO 8601)");
196
+ return;
197
+ }
198
+ const logPath = manager.getLogPath(params.id);
199
+ if (!logPath || !fs.existsSync(logPath)) {
200
+ sendOk(res, "");
201
+ return;
202
+ }
203
+ const matching = meta.runHistory.filter((r) => {
204
+ const t = new Date(r.startedAt).getTime();
205
+ return t >= fromMs && t <= toMs;
206
+ });
207
+ if (matching.length === 0) {
208
+ sendOk(res, "");
209
+ return;
210
+ }
211
+ matching.sort((a, b) => a.logOffset - b.logOffset);
212
+ const buffer = fs.readFileSync(logPath);
213
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
214
+ const parts = matching.map((record) => {
215
+ const start = record.logOffset;
216
+ const idx = allSorted.indexOf(record);
217
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
218
+ return buffer.toString("utf-8", start, end);
219
+ });
220
+ sendOk(res, parts.join(""));
221
+ });
116
222
  // --- Logs ---
117
223
  r("GET", "/api/loops/:id/logs", (_req, res, params) => {
118
224
  const logPath = manager.getLogPath(params.id);
@@ -1,5 +1,6 @@
1
1
  import { initSseResponse } from "./sse.js";
2
2
  import { buildOpenApiSpec, buildSwaggerHtml } from "./openapi.js";
3
+ import { sendOk, sendError, readBody } from "./helpers.js";
3
4
  export function registerMiscRoutes(sseClients, r) {
4
5
  r("GET", "/api/openapi.json", (_req, res) => {
5
6
  const spec = buildOpenApiSpec();
@@ -34,3 +35,18 @@ export function registerMiscRoutes(sseClients, r) {
34
35
  });
35
36
  });
36
37
  }
38
+ export function registerSettingsRoutes(settingsManager, r) {
39
+ r("GET", "/api/settings", (_req, res) => {
40
+ sendOk(res, settingsManager.get());
41
+ });
42
+ r("PATCH", "/api/settings", async (req, res) => {
43
+ try {
44
+ const body = await readBody(req);
45
+ const updated = settingsManager.set(body);
46
+ sendOk(res, updated);
47
+ }
48
+ catch (err) {
49
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
50
+ }
51
+ });
52
+ }
@@ -10,7 +10,7 @@ export function registerProjectRoutes(projectManager, r) {
10
10
  sendError(res, 400, "Project name is required");
11
11
  return;
12
12
  }
13
- const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff");
13
+ const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff", body.directory, body.githubSource);
14
14
  sendOk(res, project, 201);
15
15
  }
16
16
  catch (err) {
@@ -24,7 +24,7 @@ export function registerProjectRoutes(projectManager, r) {
24
24
  sendError(res, 400, "Project name is required");
25
25
  return;
26
26
  }
27
- projectManager.update(params.id, body.name.trim(), body.color);
27
+ projectManager.update(params.id, body.name.trim(), body.color, body.directory, body.githubSource);
28
28
  sendOk(res);
29
29
  }
30
30
  catch (err) {
@@ -1,3 +1,4 @@
1
+ import { validateContext } from "../../core/context/validate-context.js";
1
2
  import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
2
3
  export function registerTaskRoutes(taskManager, r) {
3
4
  r("GET", "/api/tasks", (_req, res) => {
@@ -22,6 +23,14 @@ export function registerTaskRoutes(taskManager, r) {
22
23
  sendError(res, 400, "Task command is required");
23
24
  return;
24
25
  }
26
+ if (body.context !== undefined) {
27
+ const result = validateContext(body.context);
28
+ if (!result.valid) {
29
+ sendError(res, 400, result.error);
30
+ return;
31
+ }
32
+ body.context = result.context;
33
+ }
25
34
  const task = taskManager.create(body);
26
35
  sendOk(res, task, 201);
27
36
  }
@@ -32,6 +41,14 @@ export function registerTaskRoutes(taskManager, r) {
32
41
  r("PATCH", "/api/tasks/:id", async (req, res, params) => {
33
42
  try {
34
43
  const body = await readBody(req);
44
+ if (body.context !== undefined) {
45
+ const result = validateContext(body.context);
46
+ if (!result.valid) {
47
+ sendError(res, 400, result.error);
48
+ return;
49
+ }
50
+ body.context = result.context;
51
+ }
35
52
  const updated = taskManager.update(params.id, body);
36
53
  if (!updated) {
37
54
  sendNotFound(res, params.id);
@@ -50,4 +67,62 @@ export function registerTaskRoutes(taskManager, r) {
50
67
  }
51
68
  sendOk(res);
52
69
  });
70
+ r("POST", "/api/task-chains", async (req, res) => {
71
+ try {
72
+ const body = await readBody(req);
73
+ if (!body.tasks || !Array.isArray(body.tasks) || body.tasks.length === 0) {
74
+ sendError(res, 400, "tasks array must not be empty");
75
+ return;
76
+ }
77
+ if (body.chain && body.chain !== "sequential-success" && body.chain !== "sequential-failure" && body.chain !== "none") {
78
+ sendError(res, 400, `Invalid chain mode: "${body.chain}". Must be "sequential-success", "sequential-failure", or "none"`);
79
+ return;
80
+ }
81
+ for (const task of body.tasks) {
82
+ if (!task.name?.trim()) {
83
+ sendError(res, 400, "Each task must have a name");
84
+ return;
85
+ }
86
+ if (!task.command?.trim()) {
87
+ sendError(res, 400, "Each task must have a command");
88
+ return;
89
+ }
90
+ }
91
+ const created = [];
92
+ try {
93
+ for (const taskInput of body.tasks) {
94
+ const task = taskManager.create(taskInput);
95
+ created.push(task);
96
+ }
97
+ const chainMode = body.chain ?? "none";
98
+ if (chainMode === "sequential-success") {
99
+ for (let i = 0; i < created.length - 1; i++) {
100
+ taskManager.update(created[i].id, {
101
+ ...created[i],
102
+ onSuccessTaskId: created[i + 1].id,
103
+ });
104
+ }
105
+ }
106
+ else if (chainMode === "sequential-failure") {
107
+ for (let i = 0; i < created.length - 1; i++) {
108
+ taskManager.update(created[i].id, {
109
+ ...created[i],
110
+ onFailureTaskId: created[i + 1].id,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ catch (err) {
116
+ for (const task of created) {
117
+ taskManager.delete(task.id);
118
+ }
119
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
120
+ return;
121
+ }
122
+ sendOk(res, { taskIds: created.map((t) => t.id) }, 201);
123
+ }
124
+ catch (err) {
125
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
126
+ }
127
+ });
53
128
  }
@@ -1,9 +1,9 @@
1
1
  import { registerLoopRoutes } from "./route-loops.js";
2
2
  import { registerTaskRoutes } from "./route-tasks.js";
3
3
  import { registerProjectRoutes } from "./route-projects.js";
4
- import { registerMiscRoutes } from "./route-misc.js";
4
+ import { registerMiscRoutes, registerSettingsRoutes } from "./route-misc.js";
5
5
  export function registerRoutes(deps) {
6
- const { manager, taskManager, projectManager, sseClients } = deps;
6
+ const { manager, taskManager, projectManager, sseClients, settingsManager } = deps;
7
7
  const routes = [];
8
8
  const r = (method, path, handler) => {
9
9
  const segments = path.split("/").filter((s) => s.length > 0);
@@ -13,5 +13,6 @@ export function registerRoutes(deps) {
13
13
  registerTaskRoutes(taskManager, r);
14
14
  registerProjectRoutes(projectManager, r);
15
15
  registerMiscRoutes(sseClients, r);
16
+ registerSettingsRoutes(settingsManager, r);
16
17
  return routes;
17
18
  }
@@ -5,18 +5,21 @@ import { sendError, matchRoute, parsePath } from "./helpers.js";
5
5
  import { SseClientSet } from "./sse.js";
6
6
  import { registerRoutes } from "./routes.js";
7
7
  export class HttpApiServer {
8
- constructor(manager, taskManager, projectManager) {
8
+ constructor(manager, taskManager, projectManager, settingsManager) {
9
9
  this.manager = manager;
10
10
  this.taskManager = taskManager;
11
11
  this.projectManager = projectManager;
12
+ this.settingsManager = settingsManager;
12
13
  this.routes = [];
13
14
  this.sseClients = new SseClientSet();
15
+ this.isListening = false;
14
16
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
15
17
  this.routes = registerRoutes({
16
18
  manager: this.manager,
17
19
  taskManager: this.taskManager,
18
20
  projectManager: this.projectManager,
19
21
  sseClients: this.sseClients,
22
+ settingsManager: this.settingsManager,
20
23
  });
21
24
  }
22
25
  async listen(port = HTTP_API_PORT, host = HTTP_API_HOST) {
@@ -31,17 +34,32 @@ export class HttpApiServer {
31
34
  }
32
35
  });
33
36
  this.server.listen(port, host, () => {
37
+ this.isListening = true;
34
38
  daemonLog(`HTTP API server listening on ${host}:${port}`);
35
39
  resolve();
36
40
  });
37
41
  });
38
42
  }
39
43
  async close() {
44
+ if (!this.isListening)
45
+ return;
46
+ this.isListening = false;
40
47
  this.sseClients.destroyAll();
41
48
  return new Promise((resolve) => {
42
- this.server.close(() => resolve());
49
+ this.server.close(() => {
50
+ resolve();
51
+ });
52
+ // Node's server.close() stops accepting new connections but leaves
53
+ // existing keep-alive sockets open, so a browser tab (e.g. Swagger UI)
54
+ // keeps being served on its persistent connection. Force them closed so
55
+ // the port is actually released when the API is toggled off.
56
+ this.server.closeAllConnections?.();
43
57
  });
44
58
  }
59
+ async restart(port = HTTP_API_PORT, host = HTTP_API_HOST) {
60
+ await this.close();
61
+ await this.listen(port, host);
62
+ }
45
63
  broadcastEvent(event, data) {
46
64
  this.sseClients.broadcast(event, data);
47
65
  }
@@ -2,20 +2,27 @@ import { LoopManager } from "./managers/loop-manager.js";
2
2
  import { TaskManager } from "./managers/task-manager.js";
3
3
  import { IpcServer } from "./server/index.js";
4
4
  import { HttpApiServer } from "./http/server.js";
5
+ import { McpApiServer } from "./mcp/index.js";
5
6
  import { FileWatcher } from "./watcher/index.js";
7
+ import { SettingsManager } from "./settings-manager.js";
6
8
  import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, setSelfWriteNotifier, } from "./state/index.js";
7
9
  import { setProjectSelfWriteNotifier } from "./managers/project-manager.js";
8
10
  import { t } from "../shared/i18n/index.js";
9
11
  import { daemonLog } from "./daemon-log.js";
10
12
  async function main() {
13
+ const mcpEnabled = (process.env.LOOP_CLI_MCP_ENABLED ?? "true") !== "false";
14
+ const mcpTransport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
11
15
  const taskManager = new TaskManager();
12
16
  migrateLoopsToJson();
13
17
  migrateTasksToJson();
14
18
  taskManager.init();
15
19
  const manager = new LoopManager(taskManager);
16
- const server = new IpcServer(manager, taskManager);
20
+ const settingsManager = new SettingsManager();
21
+ settingsManager.load();
22
+ const server = new IpcServer(manager, taskManager, settingsManager);
17
23
  const projectManager = manager.projectManager;
18
- const httpServer = new HttpApiServer(manager, taskManager, projectManager);
24
+ const httpServer = new HttpApiServer(manager, taskManager, projectManager, settingsManager);
25
+ const mcpServer = new McpApiServer(manager, taskManager, projectManager, mcpTransport);
19
26
  try {
20
27
  await server.listen();
21
28
  }
@@ -23,13 +30,46 @@ async function main() {
23
30
  daemonLog(`listen failed (another daemon already holds the socket): ${String(err)}`);
24
31
  process.exit(0);
25
32
  }
26
- try {
27
- const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
28
- await httpServer.listen(Number.isNaN(httpPort) ? undefined : httpPort);
33
+ const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
34
+ const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
35
+ if (settingsManager.get().httpApiEnabled) {
36
+ try {
37
+ await httpServer.listen(resolvedHttpPort);
38
+ }
39
+ catch (err) {
40
+ daemonLog(`HTTP API server failed to start: ${String(err)}`);
41
+ }
29
42
  }
30
- catch (err) {
31
- daemonLog(`HTTP API server failed to start: ${String(err)}`);
43
+ if (mcpEnabled) {
44
+ try {
45
+ await mcpServer.start();
46
+ }
47
+ catch (err) {
48
+ daemonLog(`MCP server failed to start: ${String(err)}`);
49
+ }
32
50
  }
51
+ settingsManager.onChange((settings) => {
52
+ if (settings.httpApiEnabled && !httpServer["isListening"]) {
53
+ httpServer.listen(resolvedHttpPort).catch((err) => {
54
+ daemonLog(`HTTP API server failed to restart: ${String(err)}`);
55
+ });
56
+ }
57
+ else if (!settings.httpApiEnabled && httpServer["isListening"]) {
58
+ httpServer.close().catch((err) => {
59
+ daemonLog(`HTTP API server failed to stop: ${String(err)}`);
60
+ });
61
+ }
62
+ if (settings.mcpApiEnabled && !mcpServer.isListening) {
63
+ mcpServer.start().catch((err) => {
64
+ daemonLog(`MCP server failed to restart: ${String(err)}`);
65
+ });
66
+ }
67
+ else if (!settings.mcpApiEnabled && mcpServer.isListening) {
68
+ mcpServer.close().catch((err) => {
69
+ daemonLog(`MCP server failed to stop: ${String(err)}`);
70
+ });
71
+ }
72
+ });
33
73
  manager.init();
34
74
  writeDaemonPid(process.pid);
35
75
  writeDaemonSignature(computeCodeSignature());
@@ -53,6 +93,7 @@ async function main() {
53
93
  await manager.shutdown();
54
94
  await server.close();
55
95
  await httpServer.close();
96
+ await mcpServer.close();
56
97
  }
57
98
  catch (err) {
58
99
  daemonLog(`error during shutdown: ${String(err)}`);
@@ -20,5 +20,7 @@ export function metaToState(meta) {
20
20
  nextRunAt: meta.nextRunAt,
21
21
  remainingDelayMs: meta.remainingDelayMs,
22
22
  runHistory: meta.runHistory,
23
+ skippedCount: meta.skippedCount,
24
+ silentChainCount: meta.silentChainCount,
23
25
  };
24
26
  }
@@ -92,11 +92,11 @@ export class LoopManager {
92
92
  listProjects() {
93
93
  return this.projectManager.getAll();
94
94
  }
95
- createProject(name, color, directory) {
96
- return this.projectManager.create(name, color, directory);
95
+ createProject(name, color, directory, githubSource) {
96
+ return this.projectManager.create(name, color, directory, githubSource);
97
97
  }
98
- updateProject(id, name, color, directory) {
99
- this.projectManager.update(id, name, color, directory);
98
+ updateProject(id, name, color, directory, githubSource) {
99
+ this.projectManager.update(id, name, color, directory, githubSource);
100
100
  }
101
101
  deleteProject(id) {
102
102
  for (const [loopId, entry] of this.loops) {
@@ -12,5 +12,6 @@ export function buildLoopOptions(meta) {
12
12
  description: meta.description ?? "",
13
13
  projectId: meta.projectId ?? "default",
14
14
  offset: meta.offset ?? null,
15
+ context: meta.context,
15
16
  };
16
17
  }
@@ -27,6 +27,7 @@ export function toMeta(controller, options, intervalHuman, taskManager) {
27
27
  pid: process.pid,
28
28
  projectId: options.projectId ?? "default",
29
29
  offset: options.offset,
30
+ context: options.context,
30
31
  };
31
32
  }
32
33
  export function persistLoop(id, controller, options, intervalHuman, taskManager, lastSerialized) {
@@ -8,6 +8,12 @@ let selfWriteNotifier = null;
8
8
  export function setProjectSelfWriteNotifier(notifier) {
9
9
  selfWriteNotifier = notifier;
10
10
  }
11
+ const GITHUB_SOURCE_REGEX = /^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/;
12
+ function validateGithubSource(githubSource) {
13
+ if (!GITHUB_SOURCE_REGEX.test(githubSource)) {
14
+ throw new Error(`Invalid githubSource format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
15
+ }
16
+ }
11
17
  export class ProjectManager {
12
18
  constructor() {
13
19
  this.projects = new Map();
@@ -99,13 +105,16 @@ export class ProjectManager {
99
105
  get(id) {
100
106
  return this.projects.get(id);
101
107
  }
102
- create(name, color, directory) {
108
+ create(name, color, directory, githubSource) {
109
+ if (githubSource !== undefined && githubSource !== "")
110
+ validateGithubSource(githubSource);
103
111
  const id = crypto.randomBytes(4).toString("hex");
104
112
  const project = {
105
113
  id,
106
114
  name,
107
115
  color,
108
116
  directory: directory ?? "",
117
+ githubSource: githubSource || undefined,
109
118
  createdAt: new Date().toISOString(),
110
119
  isSystem: false,
111
120
  isDefault: false,
@@ -113,7 +122,7 @@ export class ProjectManager {
113
122
  this.saveProject(project);
114
123
  return project;
115
124
  }
116
- update(id, name, color, directory) {
125
+ update(id, name, color, directory, githubSource) {
117
126
  const project = this.projects.get(id);
118
127
  if (!project)
119
128
  throw new Error(`Project ${id} not found`);
@@ -124,6 +133,11 @@ export class ProjectManager {
124
133
  project.color = color;
125
134
  if (directory !== undefined)
126
135
  project.directory = directory;
136
+ if (githubSource !== undefined) {
137
+ if (githubSource !== "")
138
+ validateGithubSource(githubSource);
139
+ project.githubSource = githubSource || undefined;
140
+ }
127
141
  this.saveProject(project);
128
142
  }
129
143
  delete(id) {
@@ -0,0 +1,2 @@
1
+ export { McpApiServer } from "./server.js";
2
+ export { registerMcpTools } from "./tools.js";
@@ -0,0 +1,50 @@
1
+ import { buildOpenApiSpec } from "../http/openapi.js";
2
+ export function buildMcpToolDefsFromOpenApi() {
3
+ const spec = buildOpenApiSpec();
4
+ const defs = [];
5
+ if (!spec.paths)
6
+ return defs;
7
+ for (const [path, methods] of Object.entries(spec.paths)) {
8
+ for (const [method, operation] of Object.entries(methods)) {
9
+ if (!operation.summary)
10
+ continue;
11
+ const toolName = httpToToolName(method, path);
12
+ if (!toolName)
13
+ continue;
14
+ defs.push({
15
+ name: toolName,
16
+ description: operation.summary,
17
+ });
18
+ }
19
+ }
20
+ return defs;
21
+ }
22
+ function httpToToolName(method, path) {
23
+ const mappings = {
24
+ "GET /api/loops": "list_loops",
25
+ "POST /api/loops": "create_loop",
26
+ "GET /api/loops/:id": "get_loop",
27
+ "PATCH /api/loops/:id": "update_loop",
28
+ "DELETE /api/loops/:id": "delete_loop",
29
+ "POST /api/loops/:id/pause": "pause_loop",
30
+ "POST /api/loops/:id/resume": "resume_loop",
31
+ "POST /api/loops/:id/play": "resume_loop",
32
+ "POST /api/loops/:id/trigger": "trigger_loop",
33
+ "POST /api/loops/:id/stop": "stop_loop",
34
+ "POST /api/loops/stop-all": "stop_all_loops",
35
+ "GET /api/loops/:id/logs": "get_loop_logs",
36
+ "GET /api/loops/:id/runs/:num": "get_run_log",
37
+ "GET /api/tasks": "list_tasks",
38
+ "POST /api/tasks": "create_task",
39
+ "GET /api/tasks/:id": "get_task",
40
+ "PATCH /api/tasks/:id": "update_task",
41
+ "DELETE /api/tasks/:id": "delete_task",
42
+ "GET /api/projects": "list_projects",
43
+ "POST /api/projects": "create_project",
44
+ "PATCH /api/projects/:id": "update_project",
45
+ "DELETE /api/projects/:id": "delete_project",
46
+ };
47
+ const normalizedPath = path.replace(/\{[^}]+\}/g, (m) => ":" + m.slice(1, -1));
48
+ const key = `${method.toUpperCase()} ${normalizedPath}`;
49
+ return mappings[key] ?? null;
50
+ }