@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.
Files changed (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/app/App.js +132 -0
  4. package/dist/app/BoardLayout.js +5 -0
  5. package/dist/app/index.js +48 -0
  6. package/dist/app/providers/index.js +1 -0
  7. package/dist/app/router/index.js +16 -0
  8. package/dist/app/types.js +1 -0
  9. package/dist/cli/import-validator.js +152 -0
  10. package/dist/cli/import-writer.js +81 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/client/cli-format.js +7 -0
  13. package/dist/client/commands.js +162 -0
  14. package/dist/client/ipc.js +96 -0
  15. package/dist/client/project-commands.js +164 -0
  16. package/dist/core/command/command-runner.js +300 -0
  17. package/dist/core/command/process-tree.js +73 -0
  18. package/dist/core/command/resolve-cwd.js +8 -0
  19. package/dist/core/command/stdout-capture-transform.js +73 -0
  20. package/dist/core/context/context-parser.js +65 -0
  21. package/dist/core/context/log-context.js +3 -0
  22. package/dist/core/context/template.js +20 -0
  23. package/dist/core/context/validate-context.js +19 -0
  24. package/dist/core/foreground/index.js +81 -0
  25. package/dist/core/logging/bounded-log-reader.js +368 -0
  26. package/dist/core/logging/log-follower.js +26 -0
  27. package/dist/core/logging/log-parser.js +29 -0
  28. package/dist/core/logging/log-rotator.js +34 -0
  29. package/dist/core/logging/rotating-log-stream.js +137 -0
  30. package/dist/core/loop/chain-executor.js +171 -0
  31. package/dist/core/loop/delay-utils.js +65 -0
  32. package/dist/core/loop/loop-controller.js +240 -0
  33. package/dist/core/loop/loop-runner.js +194 -0
  34. package/dist/core/loop/run-executor.js +144 -0
  35. package/dist/core/loop/types.js +1 -0
  36. package/dist/core/scheduling/index.js +16 -0
  37. package/dist/daemon/daemon-log.js +11 -0
  38. package/dist/daemon/diagnostics.js +85 -0
  39. package/dist/daemon/http/helpers.js +81 -0
  40. package/dist/daemon/http/openapi.js +119 -0
  41. package/dist/daemon/http/route-loops.js +311 -0
  42. package/dist/daemon/http/route-misc.js +60 -0
  43. package/dist/daemon/http/route-projects.js +55 -0
  44. package/dist/daemon/http/route-tasks.js +141 -0
  45. package/dist/daemon/http/routes.js +19 -0
  46. package/dist/daemon/http/server.js +82 -0
  47. package/dist/daemon/http/sse.js +36 -0
  48. package/dist/daemon/index.js +189 -0
  49. package/dist/daemon/ipc/send.js +5 -0
  50. package/dist/daemon/managers/loop-entry.js +27 -0
  51. package/dist/daemon/managers/loop-manager.js +346 -0
  52. package/dist/daemon/managers/loop-options.js +17 -0
  53. package/dist/daemon/managers/loop-serialization.js +53 -0
  54. package/dist/daemon/managers/project-manager.js +170 -0
  55. package/dist/daemon/managers/task-manager.js +94 -0
  56. package/dist/daemon/mcp/index.js +2 -0
  57. package/dist/daemon/mcp/openapi-sync.js +50 -0
  58. package/dist/daemon/mcp/server.js +167 -0
  59. package/dist/daemon/mcp/tools.js +382 -0
  60. package/dist/daemon/recipe/deferred-reload.js +32 -0
  61. package/dist/daemon/recipe/diagram-reader.js +26 -0
  62. package/dist/daemon/recipe/file-writer.js +52 -0
  63. package/dist/daemon/recipe/id-remapper.js +46 -0
  64. package/dist/daemon/recipe/runtime-state.js +23 -0
  65. package/dist/daemon/recipe/scanner.js +165 -0
  66. package/dist/daemon/recipe/task-store.js +33 -0
  67. package/dist/daemon/recipe/validator.js +35 -0
  68. package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
  69. package/dist/daemon/server/handlers/index.js +101 -0
  70. package/dist/daemon/server/handlers/log-handlers.js +129 -0
  71. package/dist/daemon/server/handlers/loop-handlers.js +64 -0
  72. package/dist/daemon/server/handlers/project-handlers.js +42 -0
  73. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  74. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  75. package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
  76. package/dist/daemon/server/index.js +117 -0
  77. package/dist/daemon/settings-manager.js +73 -0
  78. package/dist/daemon/spawner/index.js +204 -0
  79. package/dist/daemon/state/index.js +209 -0
  80. package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
  81. package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
  82. package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
  83. package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
  84. package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
  85. package/dist/daemon/telemetry/index.js +6 -0
  86. package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
  87. package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
  88. package/dist/daemon/telemetry/telemetry-manager.js +147 -0
  89. package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
  90. package/dist/daemon/telemetry/telemetry-types.js +45 -0
  91. package/dist/daemon/telemetry/telemetry.js +1 -0
  92. package/dist/daemon/watcher/index.js +250 -0
  93. package/dist/duration.js +24 -0
  94. package/dist/entities/loops/filters.js +109 -0
  95. package/dist/entities/loops/index.js +1 -0
  96. package/dist/entities/projects/filters.js +69 -0
  97. package/dist/entities/projects/index.js +1 -0
  98. package/dist/entities/tasks/filters.js +41 -0
  99. package/dist/entities/tasks/index.js +1 -0
  100. package/dist/entry.js +16 -0
  101. package/dist/esm-loader.js +39 -0
  102. package/dist/features/chain-editor/ChainEditor.js +116 -0
  103. package/dist/features/chain-editor/mermaidToAscii.js +375 -0
  104. package/dist/features/chain-editor/renderChainDiagram.js +106 -0
  105. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  106. package/dist/features/code-editor/CodeEditorPreview.js +14 -0
  107. package/dist/features/code-editor/useEditorKeyboard.js +150 -0
  108. package/dist/features/code-editor/useModalDimensions.js +9 -0
  109. package/dist/features/commands/commands.js +132 -0
  110. package/dist/features/commands/useCommandHandlers.js +280 -0
  111. package/dist/features/commands/useContextualActions.js +78 -0
  112. package/dist/features/commands/useGlobalShortcuts.js +154 -0
  113. package/dist/features/forms/FormRouter.js +46 -0
  114. package/dist/features/overlays/ContextHelpModal.js +8 -0
  115. package/dist/features/overlays/DiagramModal.js +45 -0
  116. package/dist/features/overlays/ExportModal.js +51 -0
  117. package/dist/features/overlays/HelpGuideModal.js +23 -0
  118. package/dist/features/overlays/HelpModal.js +59 -0
  119. package/dist/features/overlays/OverlayStack.js +10 -0
  120. package/dist/features/overlays/ProjectsModal.js +42 -0
  121. package/dist/features/overlays/TaskPickerModal.js +62 -0
  122. package/dist/features/overlays/WelcomeScreen.js +74 -0
  123. package/dist/features/overlays/useOverlayStack.js +55 -0
  124. package/dist/features/state/useAppState.js +151 -0
  125. package/dist/logger.js +25 -0
  126. package/dist/loop-config.js +152 -0
  127. package/dist/shared/clipboard.js +111 -0
  128. package/dist/shared/config/constants.js +104 -0
  129. package/dist/shared/config/paths.js +67 -0
  130. package/dist/shared/container/index.js +24 -0
  131. package/dist/shared/fs-utils.js +44 -0
  132. package/dist/shared/hooks/useBreakpoint.js +11 -0
  133. package/dist/shared/hooks/useDaemonSettings.js +41 -0
  134. package/dist/shared/hooks/useInject.js +6 -0
  135. package/dist/shared/hooks/useLogStream.js +37 -0
  136. package/dist/shared/hooks/useLoopFormValidation.js +131 -0
  137. package/dist/shared/hooks/useLoopPolling.js +30 -0
  138. package/dist/shared/hooks/useMouseScroll.js +56 -0
  139. package/dist/shared/hooks/useUndoRedo.js +77 -0
  140. package/dist/shared/i18n/en.json +709 -0
  141. package/dist/shared/i18n/index.js +11 -0
  142. package/dist/shared/providers/InversifyProvider.js +6 -0
  143. package/dist/shared/services/export-service.js +43 -0
  144. package/dist/shared/services/log-service.js +28 -0
  145. package/dist/shared/services/loop-service.js +78 -0
  146. package/dist/shared/services/project-service.js +39 -0
  147. package/dist/shared/services/settings-service.js +102 -0
  148. package/dist/shared/services/task-service.js +41 -0
  149. package/dist/shared/services/types.js +8 -0
  150. package/dist/shared/sleep.js +17 -0
  151. package/dist/shared/tail.js +4 -0
  152. package/dist/shared/ui/Button.js +18 -0
  153. package/dist/shared/ui/DebugPanel.js +9 -0
  154. package/dist/shared/ui/FocusableButton.js +18 -0
  155. package/dist/shared/ui/FocusableInput.js +72 -0
  156. package/dist/shared/ui/FocusableList.js +48 -0
  157. package/dist/shared/ui/Modal.js +13 -0
  158. package/dist/shared/ui/SelectModal.js +78 -0
  159. package/dist/shared/ui/Toast.js +37 -0
  160. package/dist/shared/ui/format.js +117 -0
  161. package/dist/shared/ui/hooks/useHoverState.js +11 -0
  162. package/dist/shared/ui/state.js +9 -0
  163. package/dist/shared/ui/theme.js +96 -0
  164. package/dist/shared/utils/log-lines.js +13 -0
  165. package/dist/shared/utils/paste.js +37 -0
  166. package/dist/shared/utils/syntax.js +128 -0
  167. package/dist/shared/utils/validation.js +56 -0
  168. package/dist/types.js +1 -0
  169. package/dist/visual-evidence/capture.js +51 -0
  170. package/dist/visual-evidence/cli.js +85 -0
  171. package/dist/visual-evidence/evidence-required.js +71 -0
  172. package/dist/visual-evidence/index.js +10 -0
  173. package/dist/visual-evidence/launch.js +61 -0
  174. package/dist/visual-evidence/manifest.js +32 -0
  175. package/dist/visual-evidence/openspec-resolver.js +45 -0
  176. package/dist/visual-evidence/publish.js +126 -0
  177. package/dist/visual-evidence/run.js +121 -0
  178. package/dist/visual-evidence/scenario-registry.js +132 -0
  179. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  180. package/dist/widgets/command-input/CommandInput.js +133 -0
  181. package/dist/widgets/command-input/HintBar.js +9 -0
  182. package/dist/widgets/command-input/InputModes.js +72 -0
  183. package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
  184. package/dist/widgets/header/Header.js +54 -0
  185. package/dist/widgets/header/TabBar.js +20 -0
  186. package/dist/widgets/left-panel/LeftPanel.js +34 -0
  187. package/dist/widgets/left-panel/Navigator.js +107 -0
  188. package/dist/widgets/left-panel/ProjectsPage.js +76 -0
  189. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  190. package/dist/widgets/left-panel/TaskBrowser.js +132 -0
  191. package/dist/widgets/log-modal/LogModal.js +211 -0
  192. package/dist/widgets/loop-form/CreateForm.js +82 -0
  193. package/dist/widgets/loop-form/TextField.js +9 -0
  194. package/dist/widgets/loop-form/WizardForm.js +199 -0
  195. package/dist/widgets/loop-form/useCreateSteps.js +135 -0
  196. package/dist/widgets/loop-form/useHandleComplete.js +94 -0
  197. package/dist/widgets/project-form/ProjectForm.js +83 -0
  198. package/dist/widgets/right-panel/Inspector.js +41 -0
  199. package/dist/widgets/right-panel/RightPanel.js +38 -0
  200. package/dist/widgets/right-panel/RunHistory.js +187 -0
  201. package/dist/widgets/task-form/TaskForm.js +272 -0
  202. package/package.json +119 -0
@@ -0,0 +1,36 @@
1
+ export class SseClientSet {
2
+ constructor() {
3
+ this.clients = new Set();
4
+ }
5
+ add(res) {
6
+ this.clients.add(res);
7
+ }
8
+ delete(res) {
9
+ this.clients.delete(res);
10
+ }
11
+ destroyAll() {
12
+ for (const client of this.clients) {
13
+ client.destroy();
14
+ }
15
+ this.clients.clear();
16
+ }
17
+ broadcast(event, data) {
18
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data ?? {})}\n\n`;
19
+ for (const client of this.clients) {
20
+ try {
21
+ client.write(payload);
22
+ }
23
+ catch {
24
+ this.clients.delete(client);
25
+ }
26
+ }
27
+ }
28
+ }
29
+ export function initSseResponse(res) {
30
+ res.writeHead(200, {
31
+ "Content-Type": "text/event-stream",
32
+ "Cache-Control": "no-cache",
33
+ "Connection": "keep-alive",
34
+ });
35
+ res.write(": connected\n\n");
36
+ }
@@ -0,0 +1,189 @@
1
+ import { LoopManager } from "./managers/loop-manager.js";
2
+ import { TaskManager } from "./managers/task-manager.js";
3
+ import { IpcServer } from "./server/index.js";
4
+ import { HttpApiServer } from "./http/server.js";
5
+ import { McpApiServer } from "./mcp/index.js";
6
+ import { FileWatcher } from "./watcher/index.js";
7
+ import { SettingsManager } from "./settings-manager.js";
8
+ import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, setSelfWriteNotifier, readDaemonPid, isDaemonAlive, } from "./state/index.js";
9
+ import { setProjectSelfWriteNotifier } from "./managers/project-manager.js";
10
+ import { t } from "../shared/i18n/index.js";
11
+ import { daemonLog } from "./daemon-log.js";
12
+ import { killAllActiveProcesses, getActivePids } from "../core/command/command-runner.js";
13
+ import { setActivePidsGetter } from "./diagnostics.js";
14
+ import { RecipeScanner } from "./recipe/scanner.js";
15
+ import { RecipeTaskStore } from "./recipe/task-store.js";
16
+ import { DeferredReloadManager } from "./recipe/deferred-reload.js";
17
+ import { setRecipeSelfWriteNotifier } from "./recipe/file-writer.js";
18
+ import { TelemetryManager } from "./telemetry/index.js";
19
+ import path from "node:path";
20
+ function cleanupStaleProcesses() {
21
+ const oldPid = readDaemonPid();
22
+ if (oldPid !== null && !isDaemonAlive(oldPid)) {
23
+ daemonLog(`previous daemon pid=${oldPid} is not running; child processes may be orphaned`);
24
+ }
25
+ }
26
+ async function main() {
27
+ const mcpEnabled = (process.env.LOOP_CLI_MCP_ENABLED ?? "true") !== "false";
28
+ const mcpTransport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
29
+ const taskManager = new TaskManager();
30
+ setActivePidsGetter(getActivePids);
31
+ migrateLoopsToJson();
32
+ migrateTasksToJson();
33
+ taskManager.init();
34
+ const manager = new LoopManager(taskManager);
35
+ const settingsManager = new SettingsManager();
36
+ settingsManager.load();
37
+ const telemetryManager = new TelemetryManager(settingsManager.get());
38
+ manager.setTelemetryManager(telemetryManager);
39
+ const server = new IpcServer(manager, taskManager, settingsManager, telemetryManager);
40
+ const projectManager = manager.projectManager;
41
+ const httpServer = new HttpApiServer(manager, taskManager, projectManager, settingsManager);
42
+ const mcpServer = new McpApiServer(manager, taskManager, projectManager, mcpTransport);
43
+ try {
44
+ await server.listen();
45
+ }
46
+ catch (err) {
47
+ daemonLog(`listen failed (another daemon already holds the socket): ${String(err)}`);
48
+ process.exit(0);
49
+ }
50
+ const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
51
+ const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
52
+ let currentHttpHost = settingsManager.get().httpApiHost;
53
+ if (settingsManager.get().httpApiEnabled) {
54
+ try {
55
+ await httpServer.listen(resolvedHttpPort, currentHttpHost);
56
+ }
57
+ catch (err) {
58
+ daemonLog(`HTTP API server failed to start: ${String(err)}`);
59
+ }
60
+ }
61
+ mcpServer.setHost(currentHttpHost);
62
+ if (mcpEnabled) {
63
+ try {
64
+ await mcpServer.start();
65
+ }
66
+ catch (err) {
67
+ daemonLog(`MCP server failed to start: ${String(err)}`);
68
+ }
69
+ }
70
+ settingsManager.onChange((settings) => {
71
+ const newHost = settings.httpApiHost;
72
+ const hostChanged = newHost !== currentHttpHost;
73
+ // HTTP API (8845)
74
+ if (settings.httpApiEnabled && !httpServer["isListening"]) {
75
+ httpServer.listen(resolvedHttpPort, newHost).catch((err) => {
76
+ daemonLog(`HTTP API server failed to restart: ${String(err)}`);
77
+ });
78
+ }
79
+ else if (settings.httpApiEnabled && httpServer["isListening"] && hostChanged) {
80
+ httpServer.restart(resolvedHttpPort, newHost).catch((err) => {
81
+ daemonLog(`HTTP API server failed to rebind to ${newHost}: ${String(err)}`);
82
+ });
83
+ }
84
+ else if (!settings.httpApiEnabled && httpServer["isListening"]) {
85
+ httpServer.close().catch((err) => {
86
+ daemonLog(`HTTP API server failed to stop: ${String(err)}`);
87
+ });
88
+ }
89
+ // MCP SSE (8846) — shares the same bind host as the HTTP API.
90
+ mcpServer.setHost(newHost);
91
+ if (settings.mcpApiEnabled && !mcpServer.isListening) {
92
+ mcpServer.start().catch((err) => {
93
+ daemonLog(`MCP server failed to restart: ${String(err)}`);
94
+ });
95
+ }
96
+ else if (settings.mcpApiEnabled && mcpServer.isListening && hostChanged) {
97
+ mcpServer
98
+ .close()
99
+ .then(() => mcpServer.start())
100
+ .catch((err) => {
101
+ daemonLog(`MCP server failed to rebind to ${newHost}: ${String(err)}`);
102
+ });
103
+ }
104
+ else if (!settings.mcpApiEnabled && mcpServer.isListening) {
105
+ mcpServer.close().catch((err) => {
106
+ daemonLog(`MCP server failed to stop: ${String(err)}`);
107
+ });
108
+ }
109
+ // Telemetry runtime reconfiguration
110
+ telemetryManager.onSettingsChanged(settings);
111
+ currentHttpHost = newHost;
112
+ });
113
+ manager.init();
114
+ // Recipe loop infrastructure
115
+ const recipeTaskStore = new RecipeTaskStore();
116
+ taskManager.setRecipeTaskStore(recipeTaskStore);
117
+ const recipeScanner = new RecipeScanner(recipeTaskStore);
118
+ const deferredReload = new DeferredReloadManager(recipeScanner);
119
+ recipeScanner.setManagers(manager, manager["projectManager"]);
120
+ manager.setRecipeScanner(recipeScanner);
121
+ // Set up project directory change callback for recipe reloading
122
+ manager["projectManager"].setOnDirectoryChange((projectId, _oldDirectory, newDirectory) => {
123
+ recipeScanner.unloadRecipesForProject(projectId);
124
+ if (newDirectory) {
125
+ recipeScanner.scanDirectory(projectId, newDirectory);
126
+ fileWatcher.watchRecipeDirectory(projectId, path.join(newDirectory, ".loops/recipes"));
127
+ }
128
+ });
129
+ cleanupStaleProcesses();
130
+ writeDaemonPid(process.pid);
131
+ writeDaemonSignature(computeCodeSignature());
132
+ daemonLog(`started pid=${process.pid}`);
133
+ const fileWatcher = new FileWatcher();
134
+ fileWatcher.setManagers(manager, taskManager, manager["projectManager"]);
135
+ fileWatcher.start();
136
+ daemonLog(`file watcher started for hot-reloading JSON configs`);
137
+ setSelfWriteNotifier((filePath, content) => fileWatcher.registerSelfWrite(filePath, content));
138
+ setProjectSelfWriteNotifier((filePath, content) => fileWatcher.registerSelfWrite(filePath, content));
139
+ setRecipeSelfWriteNotifier((filePath, content) => fileWatcher.registerSelfWrite(filePath, content));
140
+ // Scan all project directories for recipe files
141
+ recipeScanner.scanAllProjects();
142
+ fileWatcher.setRecipeScanner(recipeScanner, deferredReload);
143
+ // Watch recipe directories for each project
144
+ for (const project of manager.listProjects()) {
145
+ if (project.directory) {
146
+ const recipesDir = path.join(project.directory, ".loops/recipes");
147
+ fileWatcher.watchRecipeDirectory(project.id, recipesDir);
148
+ }
149
+ }
150
+ // Reconcile after watchers are attached so files created during startup cannot be missed.
151
+ recipeScanner.scanAllProjects();
152
+ daemonLog(`recipe scanner initialized`);
153
+ let shuttingDown = false;
154
+ const cleanup = async () => {
155
+ if (shuttingDown)
156
+ return;
157
+ shuttingDown = true;
158
+ daemonLog(`shutting down pid=${process.pid}`);
159
+ try {
160
+ killAllActiveProcesses();
161
+ await telemetryManager.shutdown();
162
+ fileWatcher.stop();
163
+ removeDaemonPid();
164
+ removeDaemonSignature();
165
+ await manager.shutdown();
166
+ await server.close();
167
+ await httpServer.close();
168
+ await mcpServer.close();
169
+ }
170
+ catch (err) {
171
+ daemonLog(`error during shutdown: ${String(err)}`);
172
+ }
173
+ finally {
174
+ process.exit(0);
175
+ }
176
+ };
177
+ process.on("SIGINT", cleanup);
178
+ process.on("SIGTERM", cleanup);
179
+ process.on("uncaughtException", (err) => {
180
+ daemonLog(`uncaught exception: ${String(err)}`);
181
+ console.error(t("errors.daemonUncaught"), err);
182
+ void cleanup();
183
+ });
184
+ }
185
+ main().catch((err) => {
186
+ daemonLog(`failed to start: ${String(err)}`);
187
+ console.error(t("errors.daemonFailed"), err);
188
+ process.exit(1);
189
+ });
@@ -0,0 +1,5 @@
1
+ export function send(socket, message) {
2
+ if (!socket.destroyed) {
3
+ socket.write(JSON.stringify(message) + "\n");
4
+ }
5
+ }
@@ -0,0 +1,27 @@
1
+ import { LoopController } from "../../core/loop/loop-controller.js";
2
+ import { getLogPath } from "../state/index.js";
3
+ import { buildLoopOptions } from "./loop-options.js";
4
+ export function createLoopEntry(meta, taskResolver, getProjectDirectory, state, telemetryManager) {
5
+ const options = buildLoopOptions(meta);
6
+ const logPath = getLogPath(meta.id);
7
+ const projectDir = getProjectDirectory(options.projectId ?? "default");
8
+ const controller = new LoopController(meta.id, options, logPath, taskResolver, state, projectDir, telemetryManager);
9
+ return { controller, options, intervalHuman: meta.intervalHuman };
10
+ }
11
+ export function metaToState(meta) {
12
+ return {
13
+ status: meta.status,
14
+ createdAt: meta.createdAt,
15
+ runCount: meta.runCount,
16
+ sessionStartedAt: meta.sessionStartedAt,
17
+ lastRunAt: meta.lastRunAt,
18
+ lastExitCode: meta.lastExitCode,
19
+ lastDuration: meta.lastDuration,
20
+ nextRunAt: meta.nextRunAt,
21
+ remainingDelayMs: meta.remainingDelayMs,
22
+ runHistory: meta.runHistory,
23
+ skippedCount: meta.skippedCount,
24
+ silentChainCount: meta.silentChainCount,
25
+ totalRunCount: meta.totalRunCount,
26
+ };
27
+ }
@@ -0,0 +1,346 @@
1
+ import crypto from "node:crypto";
2
+ import { LoopController } from "../../core/loop/loop-controller.js";
3
+ import { ProjectManager } from "./project-manager.js";
4
+ import { saveLoop, loadAllLoops, deleteLoop as deleteLoopState, getLogPath, } from "../state/index.js";
5
+ import { daemonLog } from "../daemon-log.js";
6
+ import { createLoopEntry, metaToState } from "./loop-entry.js";
7
+ import { wireEvents, persistLoop, buildMeta, buildRecipeMeta } from "./loop-serialization.js";
8
+ import { writeRecipeOverrides } from "../recipe/file-writer.js";
9
+ export class LoopManager {
10
+ constructor(taskManager, projectManager) {
11
+ this.loops = new Map();
12
+ this.recipes = new Map();
13
+ this.recipeMeta = new Map();
14
+ this.lastSerialized = new Map();
15
+ this.recipeScanner = null;
16
+ this.telemetryManager = null;
17
+ this.taskResolver = (taskId) => this.taskManager.get(taskId);
18
+ this.taskManager = taskManager;
19
+ this.projectManager = projectManager || new ProjectManager();
20
+ }
21
+ setTelemetryManager(mgr) {
22
+ this.telemetryManager = mgr;
23
+ }
24
+ setRecipeScanner(scanner) {
25
+ this.recipeScanner = scanner;
26
+ }
27
+ getProjectDirectory(projectId) {
28
+ return this.projectManager.get(projectId)?.directory;
29
+ }
30
+ persist(id, controller, options, intervalHuman) {
31
+ persistLoop(id, controller, options, intervalHuman, this.taskManager, this.lastSerialized);
32
+ }
33
+ wireUp(id, entry) {
34
+ wireEvents(id, entry.controller, entry.options, entry.intervalHuman, () => this.persist(id, entry.controller, entry.options, entry.intervalHuman));
35
+ }
36
+ init() {
37
+ this.projectManager.init();
38
+ const saved = loadAllLoops();
39
+ let restarted = 0;
40
+ const shouldAutoStart = (s) => s !== "idle";
41
+ for (const meta of saved) {
42
+ if (!meta.projectId) {
43
+ meta.projectId = "default";
44
+ saveLoop(meta);
45
+ }
46
+ const entry = createLoopEntry(meta, this.taskResolver, this.getProjectDirectory.bind(this), metaToState(meta), this.telemetryManager ?? undefined);
47
+ this.loops.set(meta.id, entry);
48
+ this.wireUp(meta.id, entry);
49
+ if (shouldAutoStart(meta.status)) {
50
+ entry.controller.start();
51
+ restarted += 1;
52
+ }
53
+ }
54
+ if (restarted > 0) {
55
+ daemonLog(`restarted ${restarted} loop(s) from persisted state`);
56
+ }
57
+ }
58
+ start(options, intervalHuman) {
59
+ const id = crypto.randomUUID().slice(0, 8);
60
+ const logPath = getLogPath(id);
61
+ const projectDir = this.getProjectDirectory(options.projectId ?? "default");
62
+ const controller = new LoopController(id, options, logPath, this.taskResolver, undefined, projectDir, this.telemetryManager ?? undefined);
63
+ const entry = { controller, options, intervalHuman };
64
+ this.loops.set(id, entry);
65
+ controller.start();
66
+ this.wireUp(id, entry);
67
+ this.persist(id, controller, options, intervalHuman);
68
+ return id;
69
+ }
70
+ async update(id, options, intervalHuman) {
71
+ const recipeEntry = this.recipes.get(id);
72
+ if (recipeEntry) {
73
+ return this.updateRecipe(id, options, intervalHuman, recipeEntry);
74
+ }
75
+ const entry = this.loops.get(id);
76
+ if (!entry)
77
+ return false;
78
+ const executionChanged = entry.options.interval !== options.interval ||
79
+ entry.options.taskId !== options.taskId ||
80
+ entry.options.command !== options.command ||
81
+ entry.options.commandArgs.join(" ") !== options.commandArgs.join(" ") ||
82
+ entry.options.immediate !== options.immediate ||
83
+ entry.options.maxRuns !== options.maxRuns ||
84
+ entry.options.verbose !== options.verbose;
85
+ const maxRunsChanged = entry.options.maxRuns !== options.maxRuns;
86
+ Object.assign(entry.options, options);
87
+ entry.intervalHuman = intervalHuman;
88
+ if (maxRunsChanged) {
89
+ entry.controller.clearMaxRunsReached();
90
+ }
91
+ if (entry.controller.status === "running") {
92
+ entry.controller.pause(true);
93
+ }
94
+ else if (executionChanged && entry.controller.status !== "paused" && entry.controller.status !== "idle") {
95
+ entry.controller.pause();
96
+ }
97
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
98
+ return true;
99
+ }
100
+ updateRecipe(id, options, intervalHuman, entry) {
101
+ if (options.taskId !== entry.options.taskId ||
102
+ options.command !== entry.options.command ||
103
+ options.commandArgs.join(" ") !== entry.options.commandArgs.join(" ") ||
104
+ options.cwd !== entry.options.cwd ||
105
+ options.immediate !== entry.options.immediate ||
106
+ options.verbose !== entry.options.verbose ||
107
+ options.description !== entry.options.description ||
108
+ options.offset !== entry.options.offset ||
109
+ options.projectId !== entry.options.projectId) {
110
+ throw new Error("Recipe loops only allow editing interval, maxRuns, and context");
111
+ }
112
+ const intervalChanged = entry.options.interval !== options.interval;
113
+ const maxRunsChanged = entry.options.maxRuns !== options.maxRuns;
114
+ const contextChanged = JSON.stringify(entry.options.context) !== JSON.stringify(options.context);
115
+ Object.assign(entry.options, options);
116
+ entry.intervalHuman = intervalHuman;
117
+ if (maxRunsChanged) {
118
+ entry.controller.clearMaxRunsReached();
119
+ }
120
+ if (intervalChanged || maxRunsChanged || contextChanged) {
121
+ const recipeInfo = this.recipeMeta.get(id);
122
+ if (recipeInfo && this.recipeScanner) {
123
+ const recipeEntry = this.recipeScanner.getRecipe(id);
124
+ if (recipeEntry) {
125
+ try {
126
+ writeRecipeOverrides(recipeEntry.filePath, {
127
+ intervalHuman,
128
+ maxRuns: options.maxRuns,
129
+ context: options.context,
130
+ });
131
+ }
132
+ catch (err) {
133
+ daemonLog(`error writing recipe overrides for ${id}: ${String(err)}`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ if (entry.controller.status === "running" && intervalChanged) {
139
+ entry.controller.pause(true);
140
+ }
141
+ return true;
142
+ }
143
+ list() {
144
+ const result = [];
145
+ for (const [id, entry] of this.loops) {
146
+ result.push(buildMeta(id, entry, this.taskManager));
147
+ }
148
+ for (const [id, entry] of this.recipes) {
149
+ const meta = this.recipeMeta.get(id);
150
+ result.push(buildRecipeMeta(id, entry, this.taskManager, meta?.recipeFile, meta?.recipeFilePath));
151
+ }
152
+ return result;
153
+ }
154
+ listProjects() {
155
+ return this.projectManager.getAll();
156
+ }
157
+ createProject(name, color, directory, githubSource) {
158
+ return this.projectManager.create(name, color, directory, githubSource);
159
+ }
160
+ updateProject(id, name, color, directory, githubSource) {
161
+ this.projectManager.update(id, name, color, directory, githubSource);
162
+ }
163
+ deleteProject(id) {
164
+ for (const [loopId, entry] of this.loops) {
165
+ if (entry.options.projectId === id) {
166
+ entry.options.projectId = "default";
167
+ this.persist(loopId, entry.controller, entry.options, entry.intervalHuman);
168
+ }
169
+ }
170
+ if (this.recipeScanner) {
171
+ this.recipeScanner.unloadRecipesForProject(id);
172
+ }
173
+ this.projectManager.delete(id);
174
+ }
175
+ status(id) {
176
+ const userEntry = this.loops.get(id);
177
+ if (userEntry)
178
+ return buildMeta(id, userEntry, this.taskManager);
179
+ const recipeEntry = this.recipes.get(id);
180
+ if (recipeEntry) {
181
+ const meta = this.recipeMeta.get(id);
182
+ return buildRecipeMeta(id, recipeEntry, this.taskManager, meta?.recipeFile, meta?.recipeFilePath);
183
+ }
184
+ return null;
185
+ }
186
+ pause(id) {
187
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
188
+ if (!entry)
189
+ return false;
190
+ entry.controller.pause();
191
+ if (this.loops.has(id)) {
192
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
193
+ }
194
+ return true;
195
+ }
196
+ resume(id) {
197
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
198
+ if (!entry)
199
+ return false;
200
+ entry.controller.resume();
201
+ if (this.loops.has(id)) {
202
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
203
+ }
204
+ return true;
205
+ }
206
+ async stopLoop(id) {
207
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
208
+ if (!entry)
209
+ return false;
210
+ await entry.controller.stopLoop(true);
211
+ if (this.loops.has(id)) {
212
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
213
+ }
214
+ return true;
215
+ }
216
+ async stopAllLoops() {
217
+ const stops = [];
218
+ for (const [, entry] of this.loops) {
219
+ stops.push(entry.controller.stopLoop(true));
220
+ }
221
+ await Promise.all(stops);
222
+ let count = 0;
223
+ for (const [id, entry] of this.loops) {
224
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
225
+ count++;
226
+ }
227
+ for (const [, entry] of this.recipes) {
228
+ entry.controller.stopLoop(true);
229
+ count++;
230
+ }
231
+ return count;
232
+ }
233
+ playLoop(id) {
234
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
235
+ if (!entry)
236
+ return false;
237
+ const ok = entry.controller.playLoop();
238
+ if (ok && this.loops.has(id)) {
239
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
240
+ }
241
+ return ok;
242
+ }
243
+ trigger(id) {
244
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
245
+ if (!entry)
246
+ return false;
247
+ const ok = entry.controller.triggerNow();
248
+ if (ok && this.loops.has(id)) {
249
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
250
+ }
251
+ return ok;
252
+ }
253
+ isMaxRunsBlocked(id) {
254
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
255
+ return !!entry?.controller.isMaxRunsReached();
256
+ }
257
+ isRunning(id) {
258
+ const entry = this.loops.get(id) ?? this.recipes.get(id);
259
+ return entry?.controller.status === "running";
260
+ }
261
+ async delete(id) {
262
+ if (this.recipes.has(id)) {
263
+ throw new Error("Recipe loops cannot be deleted; remove the recipe file instead");
264
+ }
265
+ const entry = this.loops.get(id);
266
+ if (!entry)
267
+ return false;
268
+ await entry.controller.stop();
269
+ this.loops.delete(id);
270
+ this.lastSerialized.delete(id);
271
+ deleteLoopState(id);
272
+ return true;
273
+ }
274
+ getLogPath(id) {
275
+ if (this.loops.has(id) || this.recipes.has(id))
276
+ return getLogPath(id);
277
+ return null;
278
+ }
279
+ async shutdown() {
280
+ const stops = [];
281
+ for (const [, entry] of this.loops) {
282
+ stops.push(entry.controller.stop());
283
+ }
284
+ for (const [, entry] of this.recipes) {
285
+ stops.push(entry.controller.stop());
286
+ }
287
+ await Promise.all(stops);
288
+ this.loops.clear();
289
+ this.recipes.clear();
290
+ }
291
+ reconcile(newLoops) {
292
+ const newIds = new Set(newLoops.map((l) => l.id));
293
+ const shouldAutoStart = (s) => s !== "idle";
294
+ for (const [existingId, entry] of this.loops) {
295
+ if (!newIds.has(existingId)) {
296
+ void entry.controller.stop().then(() => {
297
+ this.loops.delete(existingId);
298
+ this.lastSerialized.delete(existingId);
299
+ deleteLoopState(existingId);
300
+ });
301
+ }
302
+ }
303
+ for (const meta of newLoops) {
304
+ const existing = this.loops.get(meta.id);
305
+ if (existing) {
306
+ const configChanged = existing.options.interval !== meta.interval ||
307
+ existing.options.command !== meta.command ||
308
+ existing.options.cwd !== (meta.cwd ?? "");
309
+ if (configChanged) {
310
+ void existing.controller.stop().then(() => {
311
+ const entry = createLoopEntry(meta, this.taskResolver, this.getProjectDirectory.bind(this), metaToState(meta), this.telemetryManager ?? undefined);
312
+ this.loops.set(meta.id, entry);
313
+ this.wireUp(meta.id, entry);
314
+ if (shouldAutoStart(meta.status)) {
315
+ entry.controller.start();
316
+ }
317
+ });
318
+ }
319
+ }
320
+ else {
321
+ const entry = createLoopEntry(meta, this.taskResolver, this.getProjectDirectory.bind(this), metaToState(meta), this.telemetryManager ?? undefined);
322
+ this.loops.set(meta.id, entry);
323
+ this.wireUp(meta.id, entry);
324
+ if (shouldAutoStart(meta.status)) {
325
+ entry.controller.start();
326
+ }
327
+ }
328
+ }
329
+ }
330
+ addRecipeLoop(id, entry, recipeFile, recipeFilePath, persist) {
331
+ this.recipes.set(id, entry);
332
+ this.recipeMeta.set(id, { isRecipe: true, recipeFile, recipeFilePath });
333
+ wireEvents(id, entry.controller, entry.options, entry.intervalHuman, persist);
334
+ }
335
+ removeRecipeLoop(id) {
336
+ const entry = this.recipes.get(id);
337
+ if (entry) {
338
+ entry.controller.stopLoop(true);
339
+ this.recipes.delete(id);
340
+ this.recipeMeta.delete(id);
341
+ }
342
+ }
343
+ isRecipeLoop(id) {
344
+ return this.recipes.has(id);
345
+ }
346
+ }
@@ -0,0 +1,17 @@
1
+ export function buildLoopOptions(meta) {
2
+ return {
3
+ interval: meta.interval,
4
+ taskId: meta.taskId ?? null,
5
+ command: meta.command,
6
+ commandArgs: meta.commandArgs,
7
+ commandRaw: meta.commandRaw,
8
+ cwd: meta.cwd ?? "",
9
+ immediate: false,
10
+ maxRuns: meta.maxRuns,
11
+ verbose: meta.verbose,
12
+ description: meta.description ?? "",
13
+ projectId: meta.projectId ?? "default",
14
+ offset: meta.offset ?? null,
15
+ context: meta.context,
16
+ };
17
+ }
@@ -0,0 +1,53 @@
1
+ import { saveLoop } from "../state/index.js";
2
+ export function wireEvents(id, controller, options, intervalHuman, persist) {
3
+ controller.on("run:start", persist);
4
+ controller.on("run:end", persist);
5
+ controller.on("paused", persist);
6
+ controller.on("resumed", persist);
7
+ controller.on("triggered", persist);
8
+ controller.on("waiting", persist);
9
+ controller.on("stopped", persist);
10
+ }
11
+ export function toMeta(controller, options, intervalHuman, taskManager) {
12
+ const runtime = controller.getMeta();
13
+ const task = options.taskId ? taskManager.get(options.taskId) : null;
14
+ return {
15
+ ...runtime,
16
+ command: task?.command ?? options.command,
17
+ commandArgs: task?.commandArgs ?? options.commandArgs,
18
+ commandRaw: options.commandRaw,
19
+ cwd: options.cwd || "",
20
+ interval: options.interval,
21
+ intervalHuman,
22
+ immediate: options.immediate,
23
+ maxRuns: options.maxRuns,
24
+ verbose: options.verbose,
25
+ description: options.description,
26
+ remainingDelayMs: runtime.remainingDelayMs,
27
+ pid: process.pid,
28
+ projectId: options.projectId ?? "default",
29
+ offset: options.offset,
30
+ context: options.context,
31
+ };
32
+ }
33
+ export function persistLoop(id, controller, options, intervalHuman, taskManager, lastSerialized) {
34
+ const meta = toMeta(controller, options, intervalHuman, taskManager);
35
+ const serialized = JSON.stringify(meta);
36
+ if (lastSerialized.get(id) === serialized) {
37
+ return;
38
+ }
39
+ lastSerialized.set(id, serialized);
40
+ saveLoop(meta);
41
+ }
42
+ export function buildMeta(id, entry, taskManager) {
43
+ return toMeta(entry.controller, entry.options, entry.intervalHuman, taskManager);
44
+ }
45
+ export function buildRecipeMeta(id, entry, taskManager, recipeFile, recipeFilePath) {
46
+ const base = toMeta(entry.controller, entry.options, entry.intervalHuman, taskManager);
47
+ return {
48
+ ...base,
49
+ isRecipe: true,
50
+ recipeFile: recipeFile ?? "",
51
+ recipeFilePath: recipeFilePath ?? "",
52
+ };
53
+ }