@plainconceptsplatform/loop-task 2.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.
- package/LICENSE +21 -0
- package/README.md +534 -0
- package/dist/app/App.js +132 -0
- package/dist/app/BoardLayout.js +5 -0
- package/dist/app/index.js +48 -0
- package/dist/app/providers/index.js +1 -0
- package/dist/app/router/index.js +16 -0
- package/dist/app/types.js +1 -0
- package/dist/cli/import-validator.js +152 -0
- package/dist/cli/import-writer.js +81 -0
- package/dist/cli.js +573 -0
- package/dist/client/cli-format.js +7 -0
- package/dist/client/commands.js +162 -0
- package/dist/client/ipc.js +96 -0
- package/dist/client/project-commands.js +164 -0
- package/dist/core/command/command-runner.js +300 -0
- package/dist/core/command/process-tree.js +73 -0
- package/dist/core/command/resolve-cwd.js +8 -0
- package/dist/core/command/stdout-capture-transform.js +73 -0
- package/dist/core/context/context-parser.js +65 -0
- package/dist/core/context/log-context.js +3 -0
- package/dist/core/context/template.js +20 -0
- package/dist/core/context/validate-context.js +19 -0
- package/dist/core/foreground/index.js +81 -0
- package/dist/core/logging/bounded-log-reader.js +368 -0
- package/dist/core/logging/log-follower.js +26 -0
- package/dist/core/logging/log-parser.js +29 -0
- package/dist/core/logging/log-rotator.js +34 -0
- package/dist/core/logging/rotating-log-stream.js +137 -0
- package/dist/core/loop/chain-executor.js +171 -0
- package/dist/core/loop/delay-utils.js +65 -0
- package/dist/core/loop/loop-controller.js +240 -0
- package/dist/core/loop/loop-runner.js +194 -0
- package/dist/core/loop/run-executor.js +144 -0
- package/dist/core/loop/types.js +1 -0
- package/dist/core/scheduling/index.js +16 -0
- package/dist/daemon/daemon-log.js +11 -0
- package/dist/daemon/diagnostics.js +85 -0
- package/dist/daemon/http/helpers.js +81 -0
- package/dist/daemon/http/openapi.js +119 -0
- package/dist/daemon/http/route-loops.js +311 -0
- package/dist/daemon/http/route-misc.js +60 -0
- package/dist/daemon/http/route-projects.js +55 -0
- package/dist/daemon/http/route-tasks.js +141 -0
- package/dist/daemon/http/routes.js +19 -0
- package/dist/daemon/http/server.js +82 -0
- package/dist/daemon/http/sse.js +36 -0
- package/dist/daemon/index.js +189 -0
- package/dist/daemon/ipc/send.js +5 -0
- package/dist/daemon/managers/loop-entry.js +27 -0
- package/dist/daemon/managers/loop-manager.js +346 -0
- package/dist/daemon/managers/loop-options.js +17 -0
- package/dist/daemon/managers/loop-serialization.js +53 -0
- package/dist/daemon/managers/project-manager.js +170 -0
- package/dist/daemon/managers/task-manager.js +94 -0
- package/dist/daemon/mcp/index.js +2 -0
- package/dist/daemon/mcp/openapi-sync.js +50 -0
- package/dist/daemon/mcp/server.js +167 -0
- package/dist/daemon/mcp/tools.js +382 -0
- package/dist/daemon/recipe/deferred-reload.js +32 -0
- package/dist/daemon/recipe/diagram-reader.js +26 -0
- package/dist/daemon/recipe/file-writer.js +52 -0
- package/dist/daemon/recipe/id-remapper.js +46 -0
- package/dist/daemon/recipe/runtime-state.js +23 -0
- package/dist/daemon/recipe/scanner.js +165 -0
- package/dist/daemon/recipe/task-store.js +33 -0
- package/dist/daemon/recipe/validator.js +35 -0
- package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
- package/dist/daemon/server/handlers/index.js +101 -0
- package/dist/daemon/server/handlers/log-handlers.js +129 -0
- package/dist/daemon/server/handlers/loop-handlers.js +64 -0
- package/dist/daemon/server/handlers/project-handlers.js +42 -0
- package/dist/daemon/server/handlers/settings-handlers.js +8 -0
- package/dist/daemon/server/handlers/task-handlers.js +20 -0
- package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
- package/dist/daemon/server/index.js +117 -0
- package/dist/daemon/settings-manager.js +73 -0
- package/dist/daemon/spawner/index.js +204 -0
- package/dist/daemon/state/index.js +209 -0
- package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
- package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
- package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
- package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
- package/dist/daemon/telemetry/index.js +6 -0
- package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
- package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
- package/dist/daemon/telemetry/telemetry-manager.js +147 -0
- package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
- package/dist/daemon/telemetry/telemetry-types.js +45 -0
- package/dist/daemon/telemetry/telemetry.js +1 -0
- package/dist/daemon/watcher/index.js +250 -0
- package/dist/duration.js +24 -0
- package/dist/entities/loops/filters.js +109 -0
- package/dist/entities/loops/index.js +1 -0
- package/dist/entities/projects/filters.js +69 -0
- package/dist/entities/projects/index.js +1 -0
- package/dist/entities/tasks/filters.js +41 -0
- package/dist/entities/tasks/index.js +1 -0
- package/dist/entry.js +16 -0
- package/dist/esm-loader.js +39 -0
- package/dist/features/chain-editor/ChainEditor.js +116 -0
- package/dist/features/chain-editor/mermaidToAscii.js +375 -0
- package/dist/features/chain-editor/renderChainDiagram.js +106 -0
- package/dist/features/code-editor/CodeEditorModal.js +84 -0
- package/dist/features/code-editor/CodeEditorPreview.js +14 -0
- package/dist/features/code-editor/useEditorKeyboard.js +150 -0
- package/dist/features/code-editor/useModalDimensions.js +9 -0
- package/dist/features/commands/commands.js +132 -0
- package/dist/features/commands/useCommandHandlers.js +280 -0
- package/dist/features/commands/useContextualActions.js +78 -0
- package/dist/features/commands/useGlobalShortcuts.js +154 -0
- package/dist/features/forms/FormRouter.js +46 -0
- package/dist/features/overlays/ContextHelpModal.js +8 -0
- package/dist/features/overlays/DiagramModal.js +45 -0
- package/dist/features/overlays/ExportModal.js +51 -0
- package/dist/features/overlays/HelpGuideModal.js +23 -0
- package/dist/features/overlays/HelpModal.js +59 -0
- package/dist/features/overlays/OverlayStack.js +10 -0
- package/dist/features/overlays/ProjectsModal.js +42 -0
- package/dist/features/overlays/TaskPickerModal.js +62 -0
- package/dist/features/overlays/WelcomeScreen.js +74 -0
- package/dist/features/overlays/useOverlayStack.js +55 -0
- package/dist/features/state/useAppState.js +151 -0
- package/dist/logger.js +25 -0
- package/dist/loop-config.js +152 -0
- package/dist/shared/clipboard.js +111 -0
- package/dist/shared/config/constants.js +104 -0
- package/dist/shared/config/paths.js +67 -0
- package/dist/shared/container/index.js +24 -0
- package/dist/shared/fs-utils.js +44 -0
- package/dist/shared/hooks/useBreakpoint.js +11 -0
- package/dist/shared/hooks/useDaemonSettings.js +41 -0
- package/dist/shared/hooks/useInject.js +6 -0
- package/dist/shared/hooks/useLogStream.js +37 -0
- package/dist/shared/hooks/useLoopFormValidation.js +131 -0
- package/dist/shared/hooks/useLoopPolling.js +30 -0
- package/dist/shared/hooks/useMouseScroll.js +56 -0
- package/dist/shared/hooks/useUndoRedo.js +77 -0
- package/dist/shared/i18n/en.json +709 -0
- package/dist/shared/i18n/index.js +11 -0
- package/dist/shared/providers/InversifyProvider.js +6 -0
- package/dist/shared/services/export-service.js +43 -0
- package/dist/shared/services/log-service.js +28 -0
- package/dist/shared/services/loop-service.js +78 -0
- package/dist/shared/services/project-service.js +39 -0
- package/dist/shared/services/settings-service.js +102 -0
- package/dist/shared/services/task-service.js +41 -0
- package/dist/shared/services/types.js +8 -0
- package/dist/shared/sleep.js +17 -0
- package/dist/shared/tail.js +4 -0
- package/dist/shared/ui/Button.js +18 -0
- package/dist/shared/ui/DebugPanel.js +9 -0
- package/dist/shared/ui/FocusableButton.js +18 -0
- package/dist/shared/ui/FocusableInput.js +72 -0
- package/dist/shared/ui/FocusableList.js +48 -0
- package/dist/shared/ui/Modal.js +13 -0
- package/dist/shared/ui/SelectModal.js +78 -0
- package/dist/shared/ui/Toast.js +37 -0
- package/dist/shared/ui/format.js +117 -0
- package/dist/shared/ui/hooks/useHoverState.js +11 -0
- package/dist/shared/ui/state.js +9 -0
- package/dist/shared/ui/theme.js +96 -0
- package/dist/shared/utils/log-lines.js +13 -0
- package/dist/shared/utils/paste.js +37 -0
- package/dist/shared/utils/syntax.js +128 -0
- package/dist/shared/utils/validation.js +56 -0
- package/dist/types.js +1 -0
- package/dist/visual-evidence/capture.js +51 -0
- package/dist/visual-evidence/cli.js +85 -0
- package/dist/visual-evidence/evidence-required.js +71 -0
- package/dist/visual-evidence/index.js +10 -0
- package/dist/visual-evidence/launch.js +61 -0
- package/dist/visual-evidence/manifest.js +32 -0
- package/dist/visual-evidence/openspec-resolver.js +45 -0
- package/dist/visual-evidence/publish.js +126 -0
- package/dist/visual-evidence/run.js +121 -0
- package/dist/visual-evidence/scenario-registry.js +132 -0
- package/dist/widgets/command-input/CommandDropdown.js +69 -0
- package/dist/widgets/command-input/CommandInput.js +133 -0
- package/dist/widgets/command-input/HintBar.js +9 -0
- package/dist/widgets/command-input/InputModes.js +72 -0
- package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
- package/dist/widgets/header/Header.js +54 -0
- package/dist/widgets/header/TabBar.js +20 -0
- package/dist/widgets/left-panel/LeftPanel.js +34 -0
- package/dist/widgets/left-panel/Navigator.js +107 -0
- package/dist/widgets/left-panel/ProjectsPage.js +76 -0
- package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
- package/dist/widgets/left-panel/TaskBrowser.js +132 -0
- package/dist/widgets/log-modal/LogModal.js +211 -0
- package/dist/widgets/loop-form/CreateForm.js +82 -0
- package/dist/widgets/loop-form/TextField.js +9 -0
- package/dist/widgets/loop-form/WizardForm.js +199 -0
- package/dist/widgets/loop-form/useCreateSteps.js +135 -0
- package/dist/widgets/loop-form/useHandleComplete.js +94 -0
- package/dist/widgets/project-form/ProjectForm.js +83 -0
- package/dist/widgets/right-panel/Inspector.js +41 -0
- package/dist/widgets/right-panel/RightPanel.js +38 -0
- package/dist/widgets/right-panel/RunHistory.js +187 -0
- package/dist/widgets/task-form/TaskForm.js +272 -0
- package/package.json +119 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { writeFileAtomic } from "../../shared/fs-utils.js";
|
|
3
|
+
import { daemonLog } from "../daemon-log.js";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { getDataDir, projectsJson } from "../../shared/config/paths.js";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
let selfWriteNotifier = null;
|
|
8
|
+
export function setProjectSelfWriteNotifier(notifier) {
|
|
9
|
+
selfWriteNotifier = notifier;
|
|
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
|
+
}
|
|
17
|
+
export class ProjectManager {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.projects = new Map();
|
|
20
|
+
this.onDirectoryChange = null;
|
|
21
|
+
}
|
|
22
|
+
setOnDirectoryChange(callback) {
|
|
23
|
+
this.onDirectoryChange = callback;
|
|
24
|
+
}
|
|
25
|
+
init() {
|
|
26
|
+
const dataDir = getDataDir();
|
|
27
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
28
|
+
this.migrateProjectsToJson();
|
|
29
|
+
this.loadProjects();
|
|
30
|
+
if (!this.projects.has("default")) {
|
|
31
|
+
this.createDefaultProject();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
migrateProjectsToJson() {
|
|
35
|
+
const jsonFile = projectsJson();
|
|
36
|
+
if (fs.existsSync(jsonFile))
|
|
37
|
+
return;
|
|
38
|
+
const oldDir = path.join(getDataDir(), "projects");
|
|
39
|
+
if (!fs.existsSync(oldDir))
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const files = fs.readdirSync(oldDir).filter((f) => f.endsWith(".json"));
|
|
43
|
+
if (files.length === 0)
|
|
44
|
+
return;
|
|
45
|
+
const projects = [];
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(path.join(oldDir, file), "utf-8");
|
|
49
|
+
const content = JSON.parse(raw);
|
|
50
|
+
if (content.id)
|
|
51
|
+
projects.push(content);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// skip corrupted files
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
writeFileAtomic(jsonFile, JSON.stringify(projects, null, 2));
|
|
58
|
+
daemonLog(`migrated ${projects.length} project(s) to projects.json`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
daemonLog(`Failed to migrate projects: ${err}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
loadProjects() {
|
|
65
|
+
this.projects.clear();
|
|
66
|
+
const jsonFile = projectsJson();
|
|
67
|
+
if (!fs.existsSync(jsonFile))
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
const raw = fs.readFileSync(jsonFile, "utf-8");
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
if (!Array.isArray(parsed))
|
|
73
|
+
return;
|
|
74
|
+
for (const item of parsed) {
|
|
75
|
+
if (item && item.id)
|
|
76
|
+
this.projects.set(item.id, item);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
daemonLog(`Failed to load projects.json: ${err}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
saveAllProjects() {
|
|
84
|
+
const all = Array.from(this.projects.values());
|
|
85
|
+
const content = JSON.stringify(all, null, 2);
|
|
86
|
+
writeFileAtomic(projectsJson(), content);
|
|
87
|
+
if (selfWriteNotifier)
|
|
88
|
+
selfWriteNotifier(projectsJson(), content);
|
|
89
|
+
}
|
|
90
|
+
saveProject(project) {
|
|
91
|
+
this.projects.set(project.id, project);
|
|
92
|
+
this.saveAllProjects();
|
|
93
|
+
}
|
|
94
|
+
createDefaultProject() {
|
|
95
|
+
const defaultProject = {
|
|
96
|
+
id: "default",
|
|
97
|
+
name: "Default",
|
|
98
|
+
color: "#ffffff",
|
|
99
|
+
directory: "",
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
isSystem: true,
|
|
102
|
+
isDefault: true,
|
|
103
|
+
};
|
|
104
|
+
this.saveProject(defaultProject);
|
|
105
|
+
}
|
|
106
|
+
getAll() {
|
|
107
|
+
return Array.from(this.projects.values());
|
|
108
|
+
}
|
|
109
|
+
get(id) {
|
|
110
|
+
return this.projects.get(id);
|
|
111
|
+
}
|
|
112
|
+
create(name, color, directory, githubSource) {
|
|
113
|
+
if (githubSource !== undefined && githubSource !== "")
|
|
114
|
+
validateGithubSource(githubSource);
|
|
115
|
+
const id = crypto.randomBytes(4).toString("hex");
|
|
116
|
+
const project = {
|
|
117
|
+
id,
|
|
118
|
+
name,
|
|
119
|
+
color,
|
|
120
|
+
directory: directory ?? "",
|
|
121
|
+
githubSource: githubSource || undefined,
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
123
|
+
isSystem: false,
|
|
124
|
+
isDefault: false,
|
|
125
|
+
};
|
|
126
|
+
this.saveProject(project);
|
|
127
|
+
return project;
|
|
128
|
+
}
|
|
129
|
+
update(id, name, color, directory, githubSource) {
|
|
130
|
+
const project = this.projects.get(id);
|
|
131
|
+
if (!project)
|
|
132
|
+
throw new Error(`Project ${id} not found`);
|
|
133
|
+
if (project.isSystem)
|
|
134
|
+
throw new Error("Cannot rename system project");
|
|
135
|
+
const oldDirectory = project.directory ?? "";
|
|
136
|
+
project.name = name;
|
|
137
|
+
if (color)
|
|
138
|
+
project.color = color;
|
|
139
|
+
if (directory !== undefined)
|
|
140
|
+
project.directory = directory;
|
|
141
|
+
if (githubSource !== undefined) {
|
|
142
|
+
if (githubSource !== "")
|
|
143
|
+
validateGithubSource(githubSource);
|
|
144
|
+
project.githubSource = githubSource || undefined;
|
|
145
|
+
}
|
|
146
|
+
this.saveProject(project);
|
|
147
|
+
if (directory !== undefined && directory !== oldDirectory) {
|
|
148
|
+
this.onDirectoryChange?.(id, oldDirectory, directory);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
delete(id) {
|
|
152
|
+
const project = this.projects.get(id);
|
|
153
|
+
if (!project)
|
|
154
|
+
throw new Error(`Project ${id} not found`);
|
|
155
|
+
if (project.isSystem)
|
|
156
|
+
throw new Error("Cannot delete system project");
|
|
157
|
+
this.projects.delete(id);
|
|
158
|
+
this.saveAllProjects();
|
|
159
|
+
}
|
|
160
|
+
reload(newProjects) {
|
|
161
|
+
this.projects.clear();
|
|
162
|
+
for (const project of newProjects) {
|
|
163
|
+
this.projects.set(project.id, project);
|
|
164
|
+
}
|
|
165
|
+
if (!this.projects.has("default")) {
|
|
166
|
+
this.createDefaultProject();
|
|
167
|
+
}
|
|
168
|
+
daemonLog(`reloaded ${newProjects.length} project(s) from external change`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { DEFAULT_TASK_MAX_RUNS } from "../../types.js";
|
|
3
|
+
import { saveTask, loadAllTasks, deleteTask as deleteTaskState } from "../state/index.js";
|
|
4
|
+
import { daemonLog } from "../daemon-log.js";
|
|
5
|
+
export class TaskManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.tasks = new Map();
|
|
8
|
+
this.recipeTaskStore = null;
|
|
9
|
+
}
|
|
10
|
+
setRecipeTaskStore(store) {
|
|
11
|
+
this.recipeTaskStore = store;
|
|
12
|
+
}
|
|
13
|
+
init() {
|
|
14
|
+
const saved = loadAllTasks();
|
|
15
|
+
for (const task of saved) {
|
|
16
|
+
this.tasks.set(task.id, {
|
|
17
|
+
...task,
|
|
18
|
+
maxRuns: task.maxRuns ?? DEFAULT_TASK_MAX_RUNS,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (saved.length > 0) {
|
|
22
|
+
daemonLog(`loaded ${saved.length} task(s) from persisted state`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
create(input) {
|
|
26
|
+
const task = {
|
|
27
|
+
...input,
|
|
28
|
+
id: input.id ?? crypto.randomUUID().slice(0, 8),
|
|
29
|
+
maxRuns: input.maxRuns ?? DEFAULT_TASK_MAX_RUNS,
|
|
30
|
+
createdAt: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
this.tasks.set(task.id, task);
|
|
33
|
+
saveTask(task);
|
|
34
|
+
return task;
|
|
35
|
+
}
|
|
36
|
+
get(id) {
|
|
37
|
+
const userTask = this.tasks.get(id) ?? null;
|
|
38
|
+
if (userTask)
|
|
39
|
+
return userTask;
|
|
40
|
+
if (this.recipeTaskStore) {
|
|
41
|
+
return this.recipeTaskStore.get(id);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
list() {
|
|
46
|
+
const userTasks = [...this.tasks.values()];
|
|
47
|
+
if (this.recipeTaskStore) {
|
|
48
|
+
return [...userTasks, ...this.recipeTaskStore.list()];
|
|
49
|
+
}
|
|
50
|
+
return userTasks;
|
|
51
|
+
}
|
|
52
|
+
update(id, input) {
|
|
53
|
+
if (this.recipeTaskStore?.has(id)) {
|
|
54
|
+
throw new Error("Recipe tasks are immutable and cannot be updated");
|
|
55
|
+
}
|
|
56
|
+
const existing = this.tasks.get(id);
|
|
57
|
+
if (!existing)
|
|
58
|
+
return null;
|
|
59
|
+
const updated = { ...existing, ...input };
|
|
60
|
+
this.tasks.set(id, updated);
|
|
61
|
+
saveTask(updated);
|
|
62
|
+
return updated;
|
|
63
|
+
}
|
|
64
|
+
delete(id) {
|
|
65
|
+
if (this.recipeTaskStore?.has(id)) {
|
|
66
|
+
throw new Error("Recipe tasks are immutable and cannot be deleted");
|
|
67
|
+
}
|
|
68
|
+
if (!this.tasks.has(id))
|
|
69
|
+
return false;
|
|
70
|
+
this.tasks.delete(id);
|
|
71
|
+
deleteTaskState(id);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
createInline(command, commandArgs) {
|
|
75
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
76
|
+
const name = [command, ...commandArgs].join(" ").slice(0, 40);
|
|
77
|
+
return this.create({
|
|
78
|
+
id,
|
|
79
|
+
name,
|
|
80
|
+
command,
|
|
81
|
+
commandArgs,
|
|
82
|
+
onSuccessTaskId: null,
|
|
83
|
+
onFailureTaskId: null,
|
|
84
|
+
maxRuns: DEFAULT_TASK_MAX_RUNS,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
reload(newTasks) {
|
|
88
|
+
this.tasks.clear();
|
|
89
|
+
for (const task of newTasks) {
|
|
90
|
+
this.tasks.set(task.id, task);
|
|
91
|
+
}
|
|
92
|
+
daemonLog(`reloaded ${newTasks.length} task(s) from external change`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
|
+
import { daemonLog } from "../daemon-log.js";
|
|
6
|
+
import { registerMcpTools } from "./tools.js";
|
|
7
|
+
const MCP_DEFAULT_PORT = 8846;
|
|
8
|
+
export class McpApiServer {
|
|
9
|
+
constructor(manager, taskManager, projectManager, transport) {
|
|
10
|
+
this.manager = manager;
|
|
11
|
+
this.taskManager = taskManager;
|
|
12
|
+
this.projectManager = projectManager;
|
|
13
|
+
this.mcpServer = null;
|
|
14
|
+
this.activeTransport = null;
|
|
15
|
+
this.httpServer = null;
|
|
16
|
+
this._isListening = false;
|
|
17
|
+
this.host = "127.0.0.1";
|
|
18
|
+
this.transportType = transport ?? "sse";
|
|
19
|
+
}
|
|
20
|
+
/** Set the bind host for the SSE transport (takes effect on next start). */
|
|
21
|
+
setHost(host) {
|
|
22
|
+
this.host = host;
|
|
23
|
+
}
|
|
24
|
+
createServer() {
|
|
25
|
+
const server = new McpServer({ name: "loop-task", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
26
|
+
registerMcpTools(server, { manager: this.manager, taskManager: this.taskManager, projectManager: this.projectManager });
|
|
27
|
+
return server;
|
|
28
|
+
}
|
|
29
|
+
get isListening() {
|
|
30
|
+
return this._isListening;
|
|
31
|
+
}
|
|
32
|
+
async start() {
|
|
33
|
+
if (this._isListening)
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
if (this.transportType === "stdio") {
|
|
37
|
+
await this.startStdio();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
await this.startSse();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
daemonLog(`MCP server failed to start: ${String(err)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async close() {
|
|
48
|
+
if (!this._isListening)
|
|
49
|
+
return;
|
|
50
|
+
this._isListening = false;
|
|
51
|
+
if (this.mcpServer) {
|
|
52
|
+
try {
|
|
53
|
+
await this.mcpServer.close();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// ignore close errors
|
|
57
|
+
}
|
|
58
|
+
this.mcpServer = null;
|
|
59
|
+
}
|
|
60
|
+
if (this.httpServer) {
|
|
61
|
+
await new Promise((resolve) => {
|
|
62
|
+
this.httpServer.close(() => resolve());
|
|
63
|
+
this.httpServer.closeAllConnections?.();
|
|
64
|
+
});
|
|
65
|
+
this.httpServer = null;
|
|
66
|
+
}
|
|
67
|
+
this.activeTransport = null;
|
|
68
|
+
daemonLog("MCP server stopped");
|
|
69
|
+
}
|
|
70
|
+
async startStdio() {
|
|
71
|
+
if (!process.stdin || !process.stdout) {
|
|
72
|
+
daemonLog("MCP stdio transport: stdin/stdout not available, skipping");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const server = this.createServer();
|
|
76
|
+
const transport = new StdioServerTransport();
|
|
77
|
+
this.mcpServer = server;
|
|
78
|
+
this.activeTransport = transport;
|
|
79
|
+
await server.connect(transport);
|
|
80
|
+
this._isListening = true;
|
|
81
|
+
daemonLog("MCP server listening on stdio");
|
|
82
|
+
transport.onclose = () => {
|
|
83
|
+
this._isListening = false;
|
|
84
|
+
daemonLog("MCP stdio transport closed");
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async startSse() {
|
|
88
|
+
const port = parseInt(process.env.LOOP_CLI_MCP_PORT ?? "", 10) || MCP_DEFAULT_PORT;
|
|
89
|
+
// Map session ID → transport for POST routing
|
|
90
|
+
const sessions = new Map();
|
|
91
|
+
this.httpServer = http.createServer(async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
94
|
+
if (req.method === "GET" && url.pathname === "/sse") {
|
|
95
|
+
// Create a fresh McpServer per SSE connection — the SDK only allows
|
|
96
|
+
// one transport per McpServer instance, so reusing one would cause
|
|
97
|
+
// "Already connected to a transport" errors on reconnect.
|
|
98
|
+
const server = this.createServer();
|
|
99
|
+
const transport = new SSEServerTransport("/message", res);
|
|
100
|
+
sessions.set(transport.sessionId, transport);
|
|
101
|
+
await server.connect(transport);
|
|
102
|
+
if (!this.mcpServer)
|
|
103
|
+
this.mcpServer = server;
|
|
104
|
+
this._isListening = true;
|
|
105
|
+
daemonLog(`MCP SSE client connected (session: ${transport.sessionId})`);
|
|
106
|
+
transport.onclose = () => {
|
|
107
|
+
sessions.delete(transport.sessionId);
|
|
108
|
+
daemonLog(`MCP SSE client disconnected (session: ${transport.sessionId})`);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
else if (req.method === "POST" && url.pathname === "/message") {
|
|
112
|
+
const body = await this.readBody(req);
|
|
113
|
+
// Route the POST to the correct transport by session ID
|
|
114
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
115
|
+
const transport = sessionId ? sessions.get(sessionId) : sessions.values().next().value;
|
|
116
|
+
if (transport) {
|
|
117
|
+
await transport.handlePostMessage(req, res, body);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
res.writeHead(400).end("No active SSE connection");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
res.writeHead(404).end("Not found");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
daemonLog(`MCP SSE request error: ${String(err)}`);
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.writeHead(500).end("Internal error");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
this.httpServer.on("error", (err) => {
|
|
136
|
+
if (err.code === "EADDRINUSE") {
|
|
137
|
+
daemonLog(`MCP SSE server: port ${port} already in use, skipping SSE transport`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
daemonLog(`MCP SSE server error: ${String(err)}`);
|
|
141
|
+
}
|
|
142
|
+
resolve();
|
|
143
|
+
});
|
|
144
|
+
this.httpServer.listen(port, this.host, () => {
|
|
145
|
+
daemonLog(`MCP SSE server listening on ${this.host}:${port}`);
|
|
146
|
+
this._isListening = true;
|
|
147
|
+
resolve();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
readBody(req) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
155
|
+
req.on("end", () => {
|
|
156
|
+
try {
|
|
157
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
158
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
resolve({});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
req.on("error", reject);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|