@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,382 @@
1
+ import { z } from "zod";
2
+ import { buildLoopOptions } from "../../loop-config.js";
3
+ import { validateContext } from "../../core/context/validate-context.js";
4
+ import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
5
+ import { tail } from "../../shared/tail.js";
6
+ import fs from "node:fs";
7
+ function ok(data) {
8
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, data }) }] };
9
+ }
10
+ function err(message) {
11
+ return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: { message } }) }], isError: true };
12
+ }
13
+ export function registerMcpTools(server, deps) {
14
+ const { manager, taskManager, projectManager } = deps;
15
+ server.registerTool("list_loops", { description: "List all loops" }, async () => {
16
+ return ok(manager.list());
17
+ });
18
+ server.registerTool("get_loop", { description: "Get loop status by ID", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
19
+ const meta = manager.status(args.id);
20
+ if (!meta)
21
+ return err(`Loop not found: ${args.id}`);
22
+ return ok(meta);
23
+ });
24
+ server.registerTool("create_loop", {
25
+ description: "Create a new loop",
26
+ inputSchema: {
27
+ command: z.string().optional().describe("Command to run"),
28
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
29
+ intervalHuman: z.string().default("5m").describe("Interval like 30s, 5m, 1h, or 'manual' for trigger-only loops"),
30
+ cwd: z.string().optional().describe("Working directory"),
31
+ description: z.string().optional().describe("Loop description"),
32
+ taskId: z.string().nullable().optional().describe("Task ID to use instead of command"),
33
+ now: z.boolean().optional().describe("Run immediately on start"),
34
+ maxRuns: z.number().nullable().optional().describe("Maximum number of runs"),
35
+ verbose: z.boolean().optional().describe("Verbose output"),
36
+ projectId: z.string().optional().describe("Project ID"),
37
+ offset: z.number().nullable().optional().describe("Offset in ms from interval boundary"),
38
+ context: z.record(z.string(), z.unknown()).optional().describe("Initial context for chain interpolation"),
39
+ },
40
+ }, async (args) => {
41
+ try {
42
+ let context;
43
+ if (args.context !== undefined) {
44
+ const result = validateContext(args.context);
45
+ if (!result.valid)
46
+ return err(result.error);
47
+ context = result.context;
48
+ }
49
+ const intervalHuman = args.intervalHuman ?? "5m";
50
+ const { options } = buildLoopOptions(intervalHuman, {
51
+ command: args.command,
52
+ commandArgs: args.commandArgs,
53
+ taskId: args.taskId,
54
+ cwd: args.cwd,
55
+ now: args.now,
56
+ maxRuns: args.maxRuns,
57
+ verbose: args.verbose,
58
+ description: args.description,
59
+ projectId: args.projectId,
60
+ offset: args.offset,
61
+ context,
62
+ });
63
+ const id = manager.start(options, intervalHuman);
64
+ return ok({ id });
65
+ }
66
+ catch (e) {
67
+ return err(e instanceof Error ? e.message : String(e));
68
+ }
69
+ });
70
+ server.registerTool("update_loop", {
71
+ description: "Update an existing loop",
72
+ inputSchema: {
73
+ id: z.string().describe("Loop ID"),
74
+ command: z.string().optional().describe("Command to run"),
75
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
76
+ intervalHuman: z.string().default("5m").describe("Interval like 30s, 5m, 1h, or 'manual'"),
77
+ cwd: z.string().optional().describe("Working directory"),
78
+ description: z.string().optional().describe("Loop description"),
79
+ taskId: z.string().nullable().optional().describe("Task ID"),
80
+ now: z.boolean().optional().describe("Run immediately"),
81
+ maxRuns: z.number().nullable().optional().describe("Maximum runs"),
82
+ verbose: z.boolean().optional().describe("Verbose output"),
83
+ projectId: z.string().optional().describe("Project ID"),
84
+ offset: z.number().nullable().optional().describe("Offset in ms"),
85
+ context: z.record(z.string(), z.unknown()).optional().describe("Context for chain interpolation"),
86
+ },
87
+ }, async (args) => {
88
+ try {
89
+ const id = args.id;
90
+ let context;
91
+ if (args.context !== undefined) {
92
+ const result = validateContext(args.context);
93
+ if (!result.valid)
94
+ return err(result.error);
95
+ context = result.context;
96
+ }
97
+ const intervalHuman = args.intervalHuman ?? "5m";
98
+ const { options } = buildLoopOptions(intervalHuman, {
99
+ command: args.command,
100
+ commandArgs: args.commandArgs,
101
+ taskId: args.taskId,
102
+ cwd: args.cwd,
103
+ now: args.now,
104
+ maxRuns: args.maxRuns,
105
+ verbose: args.verbose,
106
+ description: args.description,
107
+ projectId: args.projectId,
108
+ offset: args.offset,
109
+ context,
110
+ });
111
+ const updated = await manager.update(id, options, intervalHuman);
112
+ if (!updated)
113
+ return err(`Loop not found: ${id}`);
114
+ return ok({ id });
115
+ }
116
+ catch (e) {
117
+ return err(e instanceof Error ? e.message : String(e));
118
+ }
119
+ });
120
+ server.registerTool("delete_loop", { description: "Delete a loop by ID", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
121
+ try {
122
+ const deleted = await manager.delete(args.id);
123
+ if (!deleted)
124
+ return err(`Loop not found: ${args.id}`);
125
+ return ok();
126
+ }
127
+ catch (e) {
128
+ return err(e instanceof Error ? e.message : String(e));
129
+ }
130
+ });
131
+ server.registerTool("pause_loop", { description: "Pause a loop", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
132
+ if (!manager.pause(args.id))
133
+ return err(`Loop not found or cannot pause: ${args.id}`);
134
+ return ok();
135
+ });
136
+ server.registerTool("resume_loop", { description: "Resume a paused loop", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
137
+ if (!manager.resume(args.id))
138
+ return err(`Loop not found or cannot resume: ${args.id}`);
139
+ return ok();
140
+ });
141
+ server.registerTool("trigger_loop", { description: "Trigger a loop to run immediately", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
142
+ const id = args.id;
143
+ if (manager.isMaxRunsBlocked(id))
144
+ return err("Max runs reached");
145
+ if (manager.isRunning(id))
146
+ return err("Loop is already running");
147
+ if (!manager.trigger(id))
148
+ return err(`Loop not found: ${id}`);
149
+ return ok();
150
+ });
151
+ server.registerTool("stop_loop", { description: "Stop a running loop", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
152
+ if (!manager.stopLoop(args.id))
153
+ return err(`Loop not found or cannot stop: ${args.id}`);
154
+ return ok();
155
+ });
156
+ server.registerTool("stop_all_loops", { description: "Stop all running loops" }, async () => {
157
+ const count = manager.stopAllLoops();
158
+ return ok({ count });
159
+ });
160
+ server.registerTool("list_tasks", { description: "List all tasks" }, async () => {
161
+ return ok(taskManager.list());
162
+ });
163
+ server.registerTool("get_task", { description: "Get a task by ID", inputSchema: { id: z.string().describe("Task ID") } }, async (args) => {
164
+ const task = taskManager.get(args.id);
165
+ if (!task)
166
+ return err(`Task not found: ${args.id}`);
167
+ return ok(task);
168
+ });
169
+ server.registerTool("create_task", {
170
+ description: "Create a new task",
171
+ inputSchema: {
172
+ id: z.string().optional().describe("Task ID (auto-generated if omitted)"),
173
+ name: z.string().describe("Task name"),
174
+ command: z.string().describe("Command to run"),
175
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
176
+ commandRaw: z.string().optional().describe("Raw command string"),
177
+ steps: z.array(z.object({ commands: z.array(z.object({ command: z.string(), commandArgs: z.array(z.string()) })) })).optional().describe("Task steps"),
178
+ onSuccessTaskId: z.string().nullable().optional().describe("Task to run on success"),
179
+ onFailureTaskId: z.string().nullable().optional().describe("Task to run on failure"),
180
+ maxRuns: z.number().optional().describe("Maximum number of runs"),
181
+ silentChain: z.boolean().optional().describe("Suppress chain output"),
182
+ context: z.record(z.string(), z.unknown()).optional().describe("Context for chain interpolation"),
183
+ },
184
+ }, async (args) => {
185
+ try {
186
+ const name = args.name;
187
+ const command = args.command;
188
+ if (!name?.trim())
189
+ return err("Task name is required");
190
+ if (!command?.trim())
191
+ return err("Task command is required");
192
+ const input = {
193
+ id: args.id ?? "",
194
+ name,
195
+ command,
196
+ commandArgs: args.commandArgs ?? [],
197
+ commandRaw: args.commandRaw,
198
+ steps: args.steps,
199
+ onSuccessTaskId: args.onSuccessTaskId ?? null,
200
+ onFailureTaskId: args.onFailureTaskId ?? null,
201
+ maxRuns: args.maxRuns,
202
+ silentChain: args.silentChain,
203
+ context: args.context,
204
+ };
205
+ if (input.context !== undefined) {
206
+ const result = validateContext(input.context);
207
+ if (!result.valid)
208
+ return err(result.error);
209
+ input.context = result.context;
210
+ }
211
+ const task = taskManager.create(input);
212
+ return ok(task);
213
+ }
214
+ catch (e) {
215
+ return err(e instanceof Error ? e.message : String(e));
216
+ }
217
+ });
218
+ server.registerTool("update_task", {
219
+ description: "Update an existing task",
220
+ inputSchema: {
221
+ id: z.string().describe("Task ID"),
222
+ name: z.string().describe("Task name"),
223
+ command: z.string().describe("Command to run"),
224
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
225
+ commandRaw: z.string().optional().describe("Raw command string"),
226
+ steps: z.array(z.object({ commands: z.array(z.object({ command: z.string(), commandArgs: z.array(z.string()) })) })).optional().describe("Task steps"),
227
+ onSuccessTaskId: z.string().nullable().optional().describe("Task to run on success"),
228
+ onFailureTaskId: z.string().nullable().optional().describe("Task to run on failure"),
229
+ maxRuns: z.number().optional().describe("Maximum number of runs"),
230
+ silentChain: z.boolean().optional().describe("Suppress chain output"),
231
+ context: z.record(z.string(), z.unknown()).optional().describe("Context for chain interpolation"),
232
+ },
233
+ }, async (args) => {
234
+ try {
235
+ const id = args.id;
236
+ const name = args.name;
237
+ const command = args.command;
238
+ if (!name?.trim())
239
+ return err("Task name is required");
240
+ if (!command?.trim())
241
+ return err("Task command is required");
242
+ const input = {
243
+ name,
244
+ command,
245
+ commandArgs: args.commandArgs ?? [],
246
+ commandRaw: args.commandRaw,
247
+ steps: args.steps,
248
+ onSuccessTaskId: args.onSuccessTaskId ?? null,
249
+ onFailureTaskId: args.onFailureTaskId ?? null,
250
+ maxRuns: args.maxRuns,
251
+ silentChain: args.silentChain,
252
+ context: args.context,
253
+ };
254
+ if (input.context !== undefined) {
255
+ const result = validateContext(input.context);
256
+ if (!result.valid)
257
+ return err(result.error);
258
+ input.context = result.context;
259
+ }
260
+ const updated = taskManager.update(id, input);
261
+ if (!updated)
262
+ return err(`Task not found: ${id}`);
263
+ return ok(updated);
264
+ }
265
+ catch (e) {
266
+ return err(e instanceof Error ? e.message : String(e));
267
+ }
268
+ });
269
+ server.registerTool("delete_task", { description: "Delete a task by ID", inputSchema: { id: z.string().describe("Task ID") } }, async (args) => {
270
+ try {
271
+ if (!taskManager.delete(args.id))
272
+ return err(`Task not found: ${args.id}`);
273
+ return ok();
274
+ }
275
+ catch (e) {
276
+ return err(e instanceof Error ? e.message : String(e));
277
+ }
278
+ });
279
+ server.registerTool("list_projects", { description: "List all projects" }, async () => {
280
+ return ok(projectManager.getAll());
281
+ });
282
+ server.registerTool("get_project", { description: "Get a project by ID", inputSchema: { id: z.string().describe("Project ID") } }, async (args) => {
283
+ const project = projectManager.get(args.id);
284
+ if (!project)
285
+ return err(`Project not found: ${args.id}`);
286
+ return ok(project);
287
+ });
288
+ server.registerTool("create_project", {
289
+ description: "Create a new project",
290
+ inputSchema: {
291
+ name: z.string().describe("Project name"),
292
+ color: z.string().default("#ffffff").describe("Project color (hex)"),
293
+ directory: z.string().optional().describe("Local working directory for loops"),
294
+ githubSource: z.string().optional().describe("GitHub repository in owner/repo format"),
295
+ },
296
+ }, async (args) => {
297
+ try {
298
+ const name = args.name;
299
+ if (!name?.trim())
300
+ return err("Project name is required");
301
+ const project = projectManager.create(name.trim(), args.color ?? "#ffffff", args.directory, args.githubSource);
302
+ return ok(project);
303
+ }
304
+ catch (e) {
305
+ return err(e instanceof Error ? e.message : String(e));
306
+ }
307
+ });
308
+ server.registerTool("update_project", {
309
+ description: "Update an existing project",
310
+ inputSchema: {
311
+ id: z.string().describe("Project ID"),
312
+ name: z.string().describe("Project name"),
313
+ color: z.string().optional().describe("Project color (hex)"),
314
+ directory: z.string().optional().describe("Local working directory"),
315
+ githubSource: z.string().optional().describe("GitHub repository in owner/repo format"),
316
+ },
317
+ }, async (args) => {
318
+ try {
319
+ const name = args.name;
320
+ if (!name?.trim())
321
+ return err("Project name is required");
322
+ projectManager.update(args.id, name.trim(), args.color, args.directory, args.githubSource);
323
+ return ok();
324
+ }
325
+ catch (e) {
326
+ return err(e instanceof Error ? e.message : String(e));
327
+ }
328
+ });
329
+ server.registerTool("delete_project", { description: "Delete a project by ID", inputSchema: { id: z.string().describe("Project ID") } }, async (args) => {
330
+ try {
331
+ projectManager.delete(args.id);
332
+ return ok();
333
+ }
334
+ catch (e) {
335
+ return err(e instanceof Error ? e.message : String(e));
336
+ }
337
+ });
338
+ server.registerTool("get_loop_logs", {
339
+ description: "Fetch loop log snapshot (last N lines)",
340
+ inputSchema: {
341
+ id: z.string().describe("Loop ID"),
342
+ tail: z.number().default(LOG_TAIL_DEFAULT).describe("Number of lines from the end"),
343
+ },
344
+ }, async (args) => {
345
+ const id = args.id;
346
+ const logPath = manager.getLogPath(id);
347
+ if (!logPath)
348
+ return err(`Loop not found: ${id}`);
349
+ if (!fs.existsSync(logPath))
350
+ return ok("");
351
+ const content = fs.readFileSync(logPath, "utf-8");
352
+ return ok(tail(content, args.tail ?? LOG_TAIL_DEFAULT).join("\n"));
353
+ });
354
+ server.registerTool("get_run_log", {
355
+ description: "Get run-specific log for a loop",
356
+ inputSchema: {
357
+ id: z.string().describe("Loop ID"),
358
+ runNumber: z.number().describe("Run number"),
359
+ },
360
+ }, async (args) => {
361
+ const id = args.id;
362
+ const runNumber = args.runNumber;
363
+ const logPath = manager.getLogPath(id);
364
+ if (!logPath || !fs.existsSync(logPath))
365
+ return ok("");
366
+ const meta = manager.status(id);
367
+ if (!meta)
368
+ return err(`Loop not found: ${id}`);
369
+ const records = meta.runHistory.filter((r) => r.runNumber === runNumber).sort((a, b) => a.logOffset - b.logOffset);
370
+ if (records.length === 0)
371
+ return ok("");
372
+ const buffer = fs.readFileSync(logPath);
373
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
374
+ const parts = records.map((record) => {
375
+ const start = record.logOffset;
376
+ const idx = allSorted.indexOf(record);
377
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
378
+ return buffer.toString("utf-8", start, end);
379
+ });
380
+ return ok(parts.join(""));
381
+ });
382
+ }
@@ -0,0 +1,32 @@
1
+ import { daemonLog } from "../daemon-log.js";
2
+ export class DeferredReloadManager {
3
+ constructor(scanner) {
4
+ this.pendingReloads = new Map();
5
+ this.scanner = scanner;
6
+ }
7
+ requestReload(recipeId, filePath, controller) {
8
+ if (controller.status === "running") {
9
+ this.pendingReloads.set(recipeId, filePath);
10
+ const handler = () => {
11
+ this.pendingReloads.delete(recipeId);
12
+ controller.removeListener("stopped", handler);
13
+ daemonLog(`deferred reload triggered for recipe ${recipeId}`);
14
+ this.scanner.reloadRecipe(recipeId);
15
+ };
16
+ controller.once("stopped", handler);
17
+ daemonLog(`deferred reload queued for recipe ${recipeId} (loop running)`);
18
+ }
19
+ else {
20
+ this.scanner.reloadRecipe(recipeId);
21
+ }
22
+ }
23
+ cancelReload(recipeId) {
24
+ this.pendingReloads.delete(recipeId);
25
+ }
26
+ hasPending(recipeId) {
27
+ return this.pendingReloads.has(recipeId);
28
+ }
29
+ clear() {
30
+ this.pendingReloads.clear();
31
+ }
32
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import * as yaml from "js-yaml";
3
+ /**
4
+ * Read the `diagram` field from a recipe YAML file.
5
+ * Returns the diagram text if present, or null if the file has no diagram.
6
+ * Throws if the file cannot be read or parsed.
7
+ */
8
+ export function readRecipeDiagram(filePath) {
9
+ if (!fs.existsSync(filePath)) {
10
+ throw new Error(`Recipe file not found: ${filePath}`);
11
+ }
12
+ const raw = fs.readFileSync(filePath, "utf-8");
13
+ const data = yaml.load(raw);
14
+ if (!data || typeof data !== "object") {
15
+ throw new Error(`Recipe file is not a valid YAML mapping: ${filePath}`);
16
+ }
17
+ const diagram = data.diagram;
18
+ if (diagram === undefined || diagram === null) {
19
+ return null;
20
+ }
21
+ if (typeof diagram !== "string") {
22
+ return null;
23
+ }
24
+ // Trim trailing newline that YAML block scalars often add
25
+ return diagram.replace(/\n$/, "");
26
+ }
@@ -0,0 +1,52 @@
1
+ import fs from "node:fs";
2
+ import { parseDocument, isMap, isSeq } from "yaml";
3
+ import { writeFileAtomic } from "../../shared/fs-utils.js";
4
+ let selfWriteNotifier = null;
5
+ export function setRecipeSelfWriteNotifier(notifier) {
6
+ selfWriteNotifier = notifier;
7
+ }
8
+ /**
9
+ * Writes recipe override fields back to the YAML file.
10
+ *
11
+ * Uses the `yaml` package's Document API for AST-preserving round-trips:
12
+ * only the touched leaf fields (loops[0].intervalHuman, maxRuns, context)
13
+ * are mutated in place. Every other node, including a diagram block scalar
14
+ * holding ASCII art, survives byte-for-byte.
15
+ */
16
+ export function writeRecipeOverrides(filePath, overrides) {
17
+ if (!fs.existsSync(filePath)) {
18
+ throw new Error(`Recipe file not found: ${filePath}`);
19
+ }
20
+ const raw = fs.readFileSync(filePath, "utf-8");
21
+ const doc = parseDocument(raw);
22
+ if (doc.errors.length > 0) {
23
+ throw new Error(`Recipe file is not valid YAML: ${filePath}: ${doc.errors[0].message}`);
24
+ }
25
+ const root = doc.contents;
26
+ if (!root || !isMap(root)) {
27
+ throw new Error(`Recipe file has no root mapping: ${filePath}`);
28
+ }
29
+ const loopsNode = root.get("loops");
30
+ if (!loopsNode || !isSeq(loopsNode) || loopsNode.items.length === 0) {
31
+ throw new Error(`Recipe file has no loops: ${filePath}`);
32
+ }
33
+ const loop = loopsNode.items[0];
34
+ if (!loop || !isMap(loop)) {
35
+ throw new Error(`Recipe loops[0] is not a mapping: ${filePath}`);
36
+ }
37
+ const loopMap = loop;
38
+ if (overrides.intervalHuman !== undefined) {
39
+ loopMap.set("intervalHuman", doc.createNode(overrides.intervalHuman));
40
+ }
41
+ if (overrides.maxRuns !== undefined) {
42
+ loopMap.set("maxRuns", doc.createNode(overrides.maxRuns));
43
+ }
44
+ if (overrides.context !== undefined) {
45
+ loopMap.set("context", doc.createNode(overrides.context));
46
+ }
47
+ const content = String(doc);
48
+ writeFileAtomic(filePath, content);
49
+ if (selfWriteNotifier) {
50
+ selfWriteNotifier(filePath, content);
51
+ }
52
+ }
@@ -0,0 +1,46 @@
1
+ import crypto from "node:crypto";
2
+ import { DEFAULT_TASK_MAX_RUNS } from "../../types.js";
3
+ export function remapRecipeIds(recipe, projectId) {
4
+ const remapTable = new Map();
5
+ for (const task of recipe.tasks) {
6
+ const newId = crypto.randomBytes(4).toString("hex");
7
+ remapTable.set(task.id, newId);
8
+ }
9
+ const tasks = recipe.tasks.map((task) => {
10
+ const remappedTask = {
11
+ id: remapTable.get(task.id),
12
+ name: task.name ?? task.id,
13
+ command: task.command ?? "",
14
+ commandArgs: task.commandArgs ?? [],
15
+ onSuccessTaskId: task.onSuccessTaskId ? remapTable.get(task.onSuccessTaskId) ?? null : null,
16
+ onFailureTaskId: task.onFailureTaskId ? remapTable.get(task.onFailureTaskId) ?? null : null,
17
+ maxRuns: DEFAULT_TASK_MAX_RUNS,
18
+ createdAt: new Date().toISOString(),
19
+ isRecipeTask: true,
20
+ projectId,
21
+ };
22
+ if (task.silentChain !== undefined) {
23
+ remappedTask.silentChain = task.silentChain;
24
+ }
25
+ if (task.context !== undefined) {
26
+ remappedTask.context = task.context;
27
+ }
28
+ if (task.steps) {
29
+ remappedTask.steps = task.steps.map((step) => ({
30
+ commands: step.commands.map((cmd) => ({
31
+ command: cmd.command,
32
+ commandArgs: cmd.commandArgs,
33
+ })),
34
+ }));
35
+ }
36
+ if (task.commandRaw !== undefined) {
37
+ remappedTask.commandRaw = task.commandRaw;
38
+ }
39
+ return remappedTask;
40
+ });
41
+ const loop = { ...recipe.loops[0] };
42
+ if (loop.taskId) {
43
+ loop.taskId = remapTable.get(loop.taskId) ?? loop.taskId;
44
+ }
45
+ return { loop, tasks, remapTable };
46
+ }
@@ -0,0 +1,23 @@
1
+ import fs from "node:fs";
2
+ import { writeFileAtomic } from "../../shared/fs-utils.js";
3
+ import { getDataDir, recipeRuntimeJson } from "../../shared/config/paths.js";
4
+ function loadAll() {
5
+ try {
6
+ const parsed = JSON.parse(fs.readFileSync(recipeRuntimeJson(), "utf-8"));
7
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
8
+ ? parsed
9
+ : {};
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ export function loadRecipeRuntimeState(id) {
16
+ return loadAll()[id];
17
+ }
18
+ export function saveRecipeRuntimeState(id, state) {
19
+ fs.mkdirSync(getDataDir(), { recursive: true });
20
+ const states = loadAll();
21
+ states[id] = state;
22
+ writeFileAtomic(recipeRuntimeJson(), JSON.stringify(states, null, 2));
23
+ }