loop-task 2.1.12 → 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 (57) hide show
  1. package/dist/app/App.js +3 -1
  2. package/dist/cli.js +44 -2
  3. package/dist/client/commands.js +4 -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 +2 -2
  7. package/dist/core/loop/chain-executor.js +11 -4
  8. package/dist/core/loop/loop-controller.js +9 -0
  9. package/dist/daemon/http/openapi.js +29 -2
  10. package/dist/daemon/http/route-loops.js +85 -0
  11. package/dist/daemon/http/route-misc.js +16 -0
  12. package/dist/daemon/http/route-projects.js +2 -2
  13. package/dist/daemon/http/route-tasks.js +58 -0
  14. package/dist/daemon/http/routes.js +3 -2
  15. package/dist/daemon/http/server.js +20 -2
  16. package/dist/daemon/index.js +48 -7
  17. package/dist/daemon/managers/loop-entry.js +2 -0
  18. package/dist/daemon/managers/loop-manager.js +4 -4
  19. package/dist/daemon/managers/project-manager.js +16 -2
  20. package/dist/daemon/mcp/index.js +2 -0
  21. package/dist/daemon/mcp/openapi-sync.js +50 -0
  22. package/dist/daemon/mcp/server.js +162 -0
  23. package/dist/daemon/mcp/tools.js +368 -0
  24. package/dist/daemon/server/handlers/index.js +8 -1
  25. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  26. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  27. package/dist/daemon/server/index.js +3 -1
  28. package/dist/daemon/settings-manager.js +46 -0
  29. package/dist/entities/tasks/filters.js +1 -1
  30. package/dist/features/commands/commands.js +4 -1
  31. package/dist/features/commands/useCommandHandlers.js +32 -0
  32. package/dist/features/commands/useGlobalShortcuts.js +0 -1
  33. package/dist/features/overlays/ExportModal.js +1 -1
  34. package/dist/loop-config.js +1 -1
  35. package/dist/shared/clipboard.js +2 -2
  36. package/dist/shared/config/paths.js +3 -0
  37. package/dist/shared/container/index.js +2 -0
  38. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  39. package/dist/shared/hooks/useUndoRedo.js +1 -1
  40. package/dist/shared/i18n/en.json +23 -0
  41. package/dist/shared/services/project-service.js +4 -4
  42. package/dist/shared/services/settings-service.js +49 -0
  43. package/dist/shared/services/types.js +1 -0
  44. package/dist/shared/ui/FocusableInput.js +1 -1
  45. package/dist/shared/ui/SelectModal.js +1 -1
  46. package/dist/shared/utils/syntax.js +3 -3
  47. package/dist/widgets/header/Header.js +15 -2
  48. package/dist/widgets/left-panel/Navigator.js +3 -2
  49. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  50. package/dist/widgets/log-modal/LogModal.js +1 -1
  51. package/dist/widgets/loop-form/WizardForm.js +2 -2
  52. package/dist/widgets/loop-form/useHandleComplete.js +4 -1
  53. package/dist/widgets/project-form/ProjectForm.js +11 -2
  54. package/dist/widgets/right-panel/Inspector.js +1 -1
  55. package/dist/widgets/right-panel/RightPanel.js +1 -1
  56. package/dist/widgets/task-form/TaskForm.js +24 -4
  57. package/package.json +4 -2
package/dist/app/App.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useCallback } from "react";
3
3
  import { Box, useApp } from "ink";
4
4
  import { useLoopPolling } from "../shared/hooks/useLoopPolling.js";
5
+ import { useDaemonSettings } from "../shared/hooks/useDaemonSettings.js";
5
6
  import { useLogStream } from "../shared/hooks/useLogStream.js";
6
7
  import { useBreakpoint } from "../shared/hooks/useBreakpoint.js";
7
8
  import { useToasts } from "../shared/ui/Toast.js";
@@ -42,6 +43,7 @@ export function App(props) {
42
43
  const logService = useInject(TYPES.LogService);
43
44
  const exportService = useInject(TYPES.ExportService);
44
45
  const { loops, daemonStatus, refresh } = useLoopPolling();
46
+ const daemonSettings = useDaemonSettings();
45
47
  const { view, push, pop } = useRouter("board");
46
48
  const { toasts, push: pushToast } = useToasts();
47
49
  const breakpoint = useBreakpoint();
@@ -113,7 +115,7 @@ export function App(props) {
113
115
  pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
114
116
  };
115
117
  const commandContext = useMemo(() => ({ activeTab: s.activeTab, selectedLoop: s.selected, selectedTask: s.selectedTask, selectedProject: s.selectedProjectEntity }), [s.activeTab, s.selected, s.selectedTask]);
116
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
118
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, httpApiEnabled: daemonSettings.reachable ? daemonSettings.httpApiEnabled : undefined, mcpApiEnabled: daemonSettings.reachable ? daemonSettings.mcpApiEnabled : undefined, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
117
119
  handleCommand("edit"); }, onProjectDelete: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
118
120
  handleCommand("delete"); } }), s.debugMode ? _jsx(DebugPanel, { entries: s.debugEntries }) : null] })) : (_jsx(FormRouter, { view: view, editTarget: s.editTarget, cloneMode: s.cloneMode, editTask: s.editTask, editProject: s.editProject, pendingTaskSelection: s.pendingTaskSelection, tasks: s.tasks, projects: s.projects, currentProjectId: s.currentProjectId, cancelCreate: cancelCreate, onCreateDone: onCreateDone, handleChooseTask: handleChooseTask, cancelTask: cancelTask, onTaskDone: onTaskDone, cancelProject: cancelProject, onProjectDone: onProjectDone })) }, viewKey(view, s.editTarget, s.editTask)), isBoardView(view) ? (_jsx(CommandInput, { context: commandContext, onCommand: handleCommand, confirmState: s.confirmState, searchState: s.searchState, searchValue: s.searchValue, onSearchChange: s.handleSearchChange, onSearchSubmit: s.handleSearchSubmit, onSearchCancel: s.handleSearchCancel, onConfirmYes: handleConfirmYes, onConfirmCancel: handleConfirmCancel, onCopy: handleContextualCopy, onPanelAction: triggerContextualAction, disabled: commandInputDisabled, navOwner: inputOwner, onInputStateChange: (hasText, dropdownOpen) => { s.setCommandBarHasText(hasText); s.setCommandBarDropdownOpen(dropdownOpen); } })) : null, _jsx(OverlayStack, { commandsBrowserOpen: s.commandsBrowserOpen, commandContext: commandContext, onCommandsBrowserClose: () => s.setCommandsBrowserOpen(false), onCommandsBrowserExecute: (v) => { s.setCommandsBrowserOpen(false); handleCommand(v); }, contextHelpOpen: s.contextHelpOpen, onContextHelpClose: () => s.setContextHelpOpen(false), exportModal: s.exportModal, onExportModalClose: () => s.setExportModal(null), onExportCopy: () => pushToast("success", t("board.toastCopied")), logModalRun: s.logModalRun, logModalLoopId: s.logModalLoopId, logModalLines: s.logModalLines, logModalLoading: s.logModalLoading, onLogModalClose: () => { s.setLogModalRun(null); s.setLogModalLoopId(null); }, onLogCopy: () => pushToast("success", t("board.toastCopied")), toasts: toasts })] }));
119
121
  }
package/dist/cli.js CHANGED
@@ -187,8 +187,10 @@ projectCmd
187
187
  .description(t("cli.projectNewDescription"))
188
188
  .argument("<name>", t("cli.projectArgName"))
189
189
  .option("--color <color>", t("cli.optProjectColor"))
190
+ .option("--directory <path>", t("cli.optProjectDirectory"))
191
+ .option("--github-source <owner/repo>", t("cli.optProjectGithubSource"))
190
192
  .action(async (name, opts) => {
191
- await createProjectCli(name, opts.color);
193
+ await createProjectCli(name, opts.color, opts.directory, opts.githubSource);
192
194
  });
193
195
  projectCmd
194
196
  .command("rename")
@@ -221,6 +223,7 @@ program
221
223
  .command("status")
222
224
  .description("Show status of all loops")
223
225
  .option("--json", "Output as JSON")
226
+ .option("--verbose", "Show extra details (skipped, silent-chain counts)")
224
227
  .action(async (opts) => {
225
228
  const { sendRequest } = await import("./client/ipc.js");
226
229
  const response = await sendRequest({ type: "list" });
@@ -235,7 +238,17 @@ program
235
238
  else {
236
239
  for (const loop of loops) {
237
240
  const { describeLoop, statusLabel } = await import("./shared/ui/format.js");
238
- console.log(`${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`);
241
+ let line = `${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`;
242
+ if (opts.verbose) {
243
+ const extra = [];
244
+ if (loop.skippedCount > 0)
245
+ extra.push(`skipped=${loop.skippedCount}`);
246
+ if ((loop.silentChainCount ?? 0) > 0)
247
+ extra.push(`silent=${loop.silentChainCount}`);
248
+ if (extra.length)
249
+ line += ` [${extra.join(", ")}]`;
250
+ }
251
+ console.log(line);
239
252
  }
240
253
  }
241
254
  });
@@ -323,4 +336,33 @@ program
323
336
  console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
324
337
  console.log(` Events: ${baseUrl}/api/events (SSE)`);
325
338
  });
339
+ program
340
+ .command("mcp")
341
+ .description("Show MCP server info and how to connect an MCP client")
342
+ .action(() => {
343
+ const transport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
344
+ const port = process.env.LOOP_CLI_MCP_PORT ?? "8846";
345
+ const sseUrl = `http://${HTTP_API_HOST}:${port}/sse`;
346
+ console.log(`MCP Server`);
347
+ console.log(` Transport: ${transport}`);
348
+ console.log(` SSE URL: ${sseUrl}`);
349
+ console.log("");
350
+ console.log(` Connect an MCP client to the SSE URL above.`);
351
+ console.log("");
352
+ if (transport === "stdio") {
353
+ console.log(` Currently using stdio transport. To switch to SSE (default),`);
354
+ console.log(` restart without LOOP_CLI_MCP_TRANSPORT=stdio.`);
355
+ console.log("");
356
+ }
357
+ console.log(` Example MCP client configs:`);
358
+ console.log("");
359
+ console.log(` OpenCode (opencode.json):`);
360
+ console.log(` { "mcp": { "loop-task": { "type": "remote", "url": "${sseUrl}" } } }`);
361
+ console.log("");
362
+ console.log(` Claude Code (.claude/mcp.json):`);
363
+ console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
364
+ console.log("");
365
+ console.log(` Cursor (.cursor/mcp.json):`);
366
+ console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
367
+ });
326
368
  await program.parseAsync(process.argv);
@@ -24,7 +24,7 @@ export async function startLoop(options, intervalHuman) {
24
24
  console.log(t("cli.startedInterval", { interval: intervalHuman }));
25
25
  console.log(t("cli.startedStatus"));
26
26
  if (options.interval === 0) {
27
- console.log(" Note: manual loopuse 'trigger' to run on demand");
27
+ console.log(" Note: manual loop use 'trigger' to run on demand");
28
28
  }
29
29
  console.log();
30
30
  console.log(t("cli.startedHint"));
@@ -80,6 +80,9 @@ export async function showStatus(id) {
80
80
  console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: intervalDisplay }));
81
81
  console.log(t("cli.statusStatus", { status: loop.status }));
82
82
  console.log(t("cli.statusRuns", { runs: loop.runCount, maxRuns }));
83
+ if ((loop.silentChainCount ?? 0) > 0) {
84
+ console.log(t("cli.statusSilentChains", { count: loop.silentChainCount.toLocaleString() }));
85
+ }
83
86
  console.log(t("cli.statusCreated", { created: loop.createdAt }));
84
87
  if (loop.lastRunAt) {
85
88
  const exitInfo = loop.lastExitCode !== null ? t("cli.exitInfo", { code: loop.lastExitCode }) : "";
@@ -70,12 +70,17 @@ export async function listProjectsCli() {
70
70
  process.exit(1);
71
71
  }
72
72
  }
73
- export async function createProjectCli(name, colorInput) {
73
+ export async function createProjectCli(name, colorInput, directory, githubSource) {
74
74
  try {
75
+ if (githubSource !== undefined && githubSource !== "") {
76
+ if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
77
+ throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
78
+ }
79
+ }
75
80
  const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
76
81
  const response = await sendRequest({
77
82
  type: "project-create",
78
- payload: { name, color },
83
+ payload: { name, color, directory, githubSource },
79
84
  });
80
85
  if (response.type !== "ok") {
81
86
  throw new Error(response.message);
@@ -5,7 +5,7 @@ function shellEscape(value) {
5
5
  if (/^[A-Za-z0-9_\-=:./,@]+$/.test(value))
6
6
  return value;
7
7
  // Escape characters that are dangerous inside double quotes, then wrap
8
- // the whole value in single quotesthe strongest shell quoting.
8
+ // the whole value in single quotes the strongest shell quoting.
9
9
  // Single quotes preserve everything literally (newlines, backticks,
10
10
  // $, ", \, parens) except single quotes themselves.
11
11
  return "'" + value.replace(/'/g, "'\\''") + "'";
@@ -9,10 +9,10 @@ export function validateContext(value) {
9
9
  for (const key of Object.keys(obj)) {
10
10
  const v = obj[key];
11
11
  if (Array.isArray(v)) {
12
- return { valid: false, error: `Context key "${key}" has an arrayonly string values are allowed` };
12
+ return { valid: false, error: `Context key "${key}" has an array only string values are allowed` };
13
13
  }
14
14
  if (typeof v === "object" && v !== null) {
15
- return { valid: false, error: `Context key "${key}" has a nested objectonly string values are allowed` };
15
+ return { valid: false, error: `Context key "${key}" has a nested object only string values are allowed` };
16
16
  }
17
17
  }
18
18
  return { valid: true, context: obj };
@@ -1,11 +1,13 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import { Writable } from "node:stream";
3
4
  import { executeCommand } from "../command/command-runner.js";
4
5
  import { t } from "../../shared/i18n/index.js";
5
6
  import { parseStdout } from "../context/context-parser.js";
6
7
  import { interpolate } from "../context/template.js";
7
8
  export function executeChain(options) {
8
9
  const { chainTargetId, exitCode, task, chainContext, cwd, signal, runCount, logPath, runHistory, logStream, controller } = options;
10
+ const nullStream = new Writable({ write(_chunk, _enc, cb) { cb(); } });
9
11
  if (!chainTargetId) {
10
12
  return Promise.resolve({ runHistory, lastExitCode: exitCode, lastDuration: 0 });
11
13
  }
@@ -23,11 +25,15 @@ export function executeChain(options) {
23
25
  const chainTask = controller.taskResolver(currentTargetId);
24
26
  if (!chainTask)
25
27
  break;
26
- if (logStream) {
28
+ const isSilent = chainTask.silentChain === true;
29
+ if (isSilent) {
30
+ controller.incrementSilentChainCount();
31
+ }
32
+ if (logStream && !isSilent) {
27
33
  logStream.write(t("loop.chainHeader", { name: chainTask.name, branch: prevBranch, prevExit }));
28
34
  }
29
35
  const chainStartedAt = new Date().toISOString();
30
- const chainOffset = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
36
+ const chainOffset = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
31
37
  runHistory.push({
32
38
  runNumber: runCount,
33
39
  startedAt: chainStartedAt,
@@ -51,8 +57,9 @@ export function executeChain(options) {
51
57
  const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
52
58
  let chainExitCode = 0;
53
59
  let chainDuration = 0;
60
+ const effectiveStream = isSilent ? nullStream : (logStream ?? nullStream);
54
61
  for (const step of taskSteps) {
55
- const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, logStream, signal, runCount, shouldCaptureStdout)));
62
+ const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, effectiveStream, signal, runCount, shouldCaptureStdout)));
56
63
  let stepStdout = "";
57
64
  for (const r of stepResults) {
58
65
  if (r.status === "fulfilled") {
@@ -74,7 +81,7 @@ export function executeChain(options) {
74
81
  break;
75
82
  }
76
83
  }
77
- const chainLogSize = fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
84
+ const chainLogSize = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
78
85
  const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
79
86
  if (chainRecord) {
80
87
  chainRecord.exitCode = chainExitCode;
@@ -27,6 +27,7 @@ export class LoopController extends EventEmitter {
27
27
  this.runHistory = [];
28
28
  this.currentRunStartOffset = 0;
29
29
  this.skippedCount = 0;
30
+ this._silentChainCount = 0;
30
31
  this.id = id;
31
32
  this.options = options;
32
33
  this.logPath = logPath;
@@ -46,10 +47,17 @@ export class LoopController extends EventEmitter {
46
47
  this._paused = state?.status === "paused" || state?.status === "idle";
47
48
  this.runHistory = (state?.runHistory ?? []).map((r) => r.status === "running" ? { ...r, status: "completed" } : r).map((r) => ({ ...r, logOffset: r.logOffset ?? 0 }));
48
49
  this.skippedCount = state?.skippedCount ?? 0;
50
+ this._silentChainCount = state?.silentChainCount ?? 0;
49
51
  }
50
52
  get status() {
51
53
  return this._status;
52
54
  }
55
+ get silentChainCount() {
56
+ return this._silentChainCount;
57
+ }
58
+ incrementSilentChainCount() {
59
+ this._silentChainCount += 1;
60
+ }
53
61
  start() {
54
62
  if (this._loopActive)
55
63
  return;
@@ -185,6 +193,7 @@ export class LoopController extends EventEmitter {
185
193
  remainingDelayMs: this.remainingDelayMs,
186
194
  runHistory: this.runHistory,
187
195
  skippedCount: this.skippedCount,
196
+ silentChainCount: this._silentChainCount,
188
197
  };
189
198
  }
190
199
  clearMaxRunsReached() {
@@ -10,6 +10,17 @@ export function buildOpenApiSpec() {
10
10
  servers: [
11
11
  { url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
12
12
  ],
13
+ tags: [
14
+ { name: "Loops", description: "Loop management" },
15
+ { name: "Tasks", description: "Task management" },
16
+ { name: "Task Chains", description: "Create chained tasks" },
17
+ { name: "Projects", description: "Project management" },
18
+ { name: "Logs", description: "Log retrieval" },
19
+ { name: "Events", description: "Server-sent events" },
20
+ { name: "Settings", description: "Daemon settings" },
21
+ { name: "MCP", description: "Model Context Protocol server" },
22
+ { name: "Docs", description: "API documentation" },
23
+ ],
13
24
  paths: {
14
25
  "/api/loops": {
15
26
  get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
@@ -22,9 +33,12 @@ export function buildOpenApiSpec() {
22
33
  },
23
34
  "/api/loops/{id}/pause": { post: { summary: "Pause a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Paused" }, "404": { description: "Not found" } } } },
24
35
  "/api/loops/{id}/resume": { post: { summary: "Resume a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Resumed" }, "404": { description: "Not found" } } } },
36
+ "/api/loops/{id}/play": { post: { summary: "Play (start) a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Playing", content: { "application/json": { schema: { type: "object" } } } }, "404": { description: "Not found" }, "409": { description: "Already running or max runs blocked" } } } },
25
37
  "/api/loops/{id}/trigger": { post: { summary: "Trigger a loop now", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Triggered" }, "404": { description: "Not found" }, "400": { description: "Max runs reached" }, "409": { description: "Already running" } } } },
26
38
  "/api/loops/{id}/stop": { post: { summary: "Stop a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Stopped" }, "404": { description: "Not found" } } } },
27
39
  "/api/loops/stop-all": { post: { summary: "Stop all loops", tags: ["Loops"], responses: { "200": { description: "All stopped", content: { "application/json": { schema: { type: "object", properties: { count: { type: "integer" } } } } } } } } },
40
+ "/api/loops/{id}/runs": { get: { summary: "List run history", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", schema: { type: "string", description: "ISO 8601 date" } }, { name: "to", in: "query", schema: { type: "string", description: "ISO 8601 date" } }], responses: { "200": { description: "Run records", content: { "application/json": { schema: { type: "array" } } } }, "404": { description: "Not found" } } } },
41
+ "/api/loops/{id}/logs/date": { get: { summary: "Date-filtered logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", required: true, schema: { type: "string", description: "ISO 8601 start date" } }, { name: "to", in: "query", required: true, schema: { type: "string", description: "ISO 8601 end date" } }], responses: { "200": { description: "Filtered log content" }, "404": { description: "Not found" }, "400": { description: "Missing or invalid date params" } } } },
28
42
  "/api/loops/{id}/logs": { get: { summary: "Fetch loop logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer", default: 50 } }], responses: { "200": { description: "Log content" }, "404": { description: "Not found" } } } },
29
43
  "/api/loops/{id}/logs/stream": { get: { summary: "Stream logs via SSE", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer" } }], responses: { "200": { description: "SSE stream" }, "404": { description: "Not found" } } } },
30
44
  "/api/loops/{id}/runs/{num}": { get: { summary: "Get run-specific log", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "num", in: "path", required: true, schema: { type: "integer" } }], responses: { "200": { description: "Run log content" } } } },
@@ -37,18 +51,31 @@ export function buildOpenApiSpec() {
37
51
  patch: { summary: "Update a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object" } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" } } },
38
52
  delete: { summary: "Delete a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "404": { description: "Not found" } } },
39
53
  },
54
+ "/api/task-chains": { post: { summary: "Create a chain of tasks", tags: ["Task Chains"], requestBody: { content: { "application/json": { schema: { type: "object", required: ["tasks"], properties: { tasks: { type: "array", items: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } }, chain: { type: "string", enum: ["sequential-success", "sequential-failure", "none"], description: "How to wire tasks together" } } } } } }, responses: { "201": { description: "Chain created", content: { "application/json": { schema: { type: "object", properties: { taskIds: { type: "array", items: { type: "string" } } } } } } }, "400": { description: "Validation error or rollback" } } } },
40
55
  "/api/projects": {
41
56
  get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
42
- post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
57
+ post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
43
58
  },
44
59
  "/api/projects/{id}": {
45
- patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
60
+ patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
46
61
  delete: { summary: "Delete a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "400": { description: "Cannot delete system project" } } },
47
62
  },
63
+ "/api/settings": { get: { summary: "Get daemon settings", tags: ["Settings"], responses: { "200": { description: "Current settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } } } }, patch: { summary: "Update daemon settings", tags: ["Settings"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, responses: { "200": { description: "Updated settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, "400": { description: "Validation error" } } } },
48
64
  "/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
49
65
  "/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
50
66
  "/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
51
67
  },
68
+ components: {
69
+ schemas: {
70
+ DaemonSettings: {
71
+ type: "object",
72
+ properties: {
73
+ httpApiEnabled: { type: "boolean", description: "Whether the HTTP API server is enabled" },
74
+ mcpApiEnabled: { type: "boolean", description: "Whether the MCP server is enabled" },
75
+ },
76
+ },
77
+ },
78
+ },
52
79
  };
53
80
  }
54
81
  export function buildSwaggerHtml() {
@@ -108,6 +108,22 @@ export function registerLoopRoutes(manager, routes, r) {
108
108
  }
109
109
  sendOk(res);
110
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
+ });
111
127
  r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
112
128
  if (manager.isMaxRunsBlocked(params.id)) {
113
129
  sendError(res, 400, "Max runs reached");
@@ -134,6 +150,75 @@ export function registerLoopRoutes(manager, routes, r) {
134
150
  const count = manager.stopAllLoops();
135
151
  sendOk(res, { count });
136
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
+ });
137
222
  // --- Logs ---
138
223
  r("GET", "/api/loops/:id/logs", (_req, res, params) => {
139
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) {
@@ -67,4 +67,62 @@ export function registerTaskRoutes(taskManager, r) {
67
67
  }
68
68
  sendOk(res);
69
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
+ });
70
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
  }