loop-task 2.1.11 → 2.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/app/App.js +3 -1
  2. package/dist/cli.js +88 -2
  3. package/dist/client/commands.js +8 -1
  4. package/dist/client/project-commands.js +7 -2
  5. package/dist/core/context/template.js +1 -1
  6. package/dist/core/context/validate-context.js +19 -0
  7. package/dist/core/loop/chain-executor.js +50 -17
  8. package/dist/core/loop/loop-controller.js +18 -0
  9. package/dist/core/loop/loop-runner.js +8 -0
  10. package/dist/core/loop/run-executor.js +1 -1
  11. package/dist/core/scheduling/index.js +4 -0
  12. package/dist/daemon/http/openapi.js +31 -4
  13. package/dist/daemon/http/route-loops.js +106 -0
  14. package/dist/daemon/http/route-misc.js +16 -0
  15. package/dist/daemon/http/route-projects.js +2 -2
  16. package/dist/daemon/http/route-tasks.js +75 -0
  17. package/dist/daemon/http/routes.js +3 -2
  18. package/dist/daemon/http/server.js +20 -2
  19. package/dist/daemon/index.js +48 -7
  20. package/dist/daemon/managers/loop-entry.js +2 -0
  21. package/dist/daemon/managers/loop-manager.js +4 -4
  22. package/dist/daemon/managers/loop-options.js +1 -0
  23. package/dist/daemon/managers/loop-serialization.js +1 -0
  24. package/dist/daemon/managers/project-manager.js +16 -2
  25. package/dist/daemon/mcp/index.js +2 -0
  26. package/dist/daemon/mcp/openapi-sync.js +50 -0
  27. package/dist/daemon/mcp/server.js +162 -0
  28. package/dist/daemon/mcp/tools.js +368 -0
  29. package/dist/daemon/server/handlers/index.js +8 -1
  30. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  31. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  32. package/dist/daemon/server/index.js +3 -1
  33. package/dist/daemon/settings-manager.js +46 -0
  34. package/dist/duration.js +5 -0
  35. package/dist/entities/loops/filters.js +2 -0
  36. package/dist/entities/tasks/filters.js +1 -1
  37. package/dist/features/commands/commands.js +4 -1
  38. package/dist/features/commands/useCommandHandlers.js +32 -0
  39. package/dist/features/commands/useGlobalShortcuts.js +0 -1
  40. package/dist/features/forms/FormRouter.js +2 -0
  41. package/dist/features/overlays/ExportModal.js +1 -1
  42. package/dist/loop-config.js +8 -4
  43. package/dist/shared/clipboard.js +2 -2
  44. package/dist/shared/config/paths.js +3 -0
  45. package/dist/shared/container/index.js +2 -0
  46. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  47. package/dist/shared/hooks/useUndoRedo.js +1 -1
  48. package/dist/shared/i18n/en.json +31 -4
  49. package/dist/shared/services/project-service.js +4 -4
  50. package/dist/shared/services/settings-service.js +49 -0
  51. package/dist/shared/services/types.js +1 -0
  52. package/dist/shared/ui/FocusableInput.js +1 -1
  53. package/dist/shared/ui/SelectModal.js +1 -1
  54. package/dist/shared/ui/format.js +2 -0
  55. package/dist/shared/utils/syntax.js +3 -3
  56. package/dist/widgets/header/Header.js +15 -2
  57. package/dist/widgets/left-panel/Navigator.js +3 -2
  58. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  59. package/dist/widgets/log-modal/LogModal.js +1 -1
  60. package/dist/widgets/loop-form/CreateForm.js +9 -2
  61. package/dist/widgets/loop-form/WizardForm.js +12 -2
  62. package/dist/widgets/loop-form/useCreateSteps.js +30 -2
  63. package/dist/widgets/loop-form/useHandleComplete.js +23 -3
  64. package/dist/widgets/project-form/ProjectForm.js +11 -2
  65. package/dist/widgets/right-panel/Inspector.js +1 -1
  66. package/dist/widgets/right-panel/RightPanel.js +1 -1
  67. package/dist/widgets/task-form/TaskForm.js +69 -4
  68. package/package.json +4 -2
package/dist/app/App.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useCallback } from "react";
3
3
  import { Box, useApp } from "ink";
4
4
  import { useLoopPolling } from "../shared/hooks/useLoopPolling.js";
5
+ import { useDaemonSettings } from "../shared/hooks/useDaemonSettings.js";
5
6
  import { useLogStream } from "../shared/hooks/useLogStream.js";
6
7
  import { useBreakpoint } from "../shared/hooks/useBreakpoint.js";
7
8
  import { useToasts } from "../shared/ui/Toast.js";
@@ -42,6 +43,7 @@ export function App(props) {
42
43
  const logService = useInject(TYPES.LogService);
43
44
  const exportService = useInject(TYPES.ExportService);
44
45
  const { loops, daemonStatus, refresh } = useLoopPolling();
46
+ const daemonSettings = useDaemonSettings();
45
47
  const { view, push, pop } = useRouter("board");
46
48
  const { toasts, push: pushToast } = useToasts();
47
49
  const breakpoint = useBreakpoint();
@@ -113,7 +115,7 @@ export function App(props) {
113
115
  pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
114
116
  };
115
117
  const commandContext = useMemo(() => ({ activeTab: s.activeTab, selectedLoop: s.selected, selectedTask: s.selectedTask, selectedProject: s.selectedProjectEntity }), [s.activeTab, s.selected, s.selectedTask]);
116
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
118
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, httpApiEnabled: daemonSettings.reachable ? daemonSettings.httpApiEnabled : undefined, mcpApiEnabled: daemonSettings.reachable ? daemonSettings.mcpApiEnabled : undefined, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
117
119
  handleCommand("edit"); }, onProjectDelete: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
118
120
  handleCommand("delete"); } }), s.debugMode ? _jsx(DebugPanel, { entries: s.debugEntries }) : null] })) : (_jsx(FormRouter, { view: view, editTarget: s.editTarget, cloneMode: s.cloneMode, editTask: s.editTask, editProject: s.editProject, pendingTaskSelection: s.pendingTaskSelection, tasks: s.tasks, projects: s.projects, currentProjectId: s.currentProjectId, cancelCreate: cancelCreate, onCreateDone: onCreateDone, handleChooseTask: handleChooseTask, cancelTask: cancelTask, onTaskDone: onTaskDone, cancelProject: cancelProject, onProjectDone: onProjectDone })) }, viewKey(view, s.editTarget, s.editTask)), isBoardView(view) ? (_jsx(CommandInput, { context: commandContext, onCommand: handleCommand, confirmState: s.confirmState, searchState: s.searchState, searchValue: s.searchValue, onSearchChange: s.handleSearchChange, onSearchSubmit: s.handleSearchSubmit, onSearchCancel: s.handleSearchCancel, onConfirmYes: handleConfirmYes, onConfirmCancel: handleConfirmCancel, onCopy: handleContextualCopy, onPanelAction: triggerContextualAction, disabled: commandInputDisabled, navOwner: inputOwner, onInputStateChange: (hasText, dropdownOpen) => { s.setCommandBarHasText(hasText); s.setCommandBarDropdownOpen(dropdownOpen); } })) : null, _jsx(OverlayStack, { commandsBrowserOpen: s.commandsBrowserOpen, commandContext: commandContext, onCommandsBrowserClose: () => s.setCommandsBrowserOpen(false), onCommandsBrowserExecute: (v) => { s.setCommandsBrowserOpen(false); handleCommand(v); }, contextHelpOpen: s.contextHelpOpen, onContextHelpClose: () => s.setContextHelpOpen(false), exportModal: s.exportModal, onExportModalClose: () => s.setExportModal(null), onExportCopy: () => pushToast("success", t("board.toastCopied")), logModalRun: s.logModalRun, logModalLoopId: s.logModalLoopId, logModalLines: s.logModalLines, logModalLoading: s.logModalLoading, onLogModalClose: () => { s.setLogModalRun(null); s.setLogModalLoopId(null); }, onLogCopy: () => pushToast("success", t("board.toastCopied")), toasts: toasts })] }));
119
121
  }
package/dist/cli.js CHANGED
@@ -79,12 +79,31 @@ program
79
79
  .option("--project <name>", t("cli.optProject"))
80
80
  .option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
81
81
  .option("--description <desc>", "Description of the loop")
82
+ .option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
82
83
  .action(async (intervalStr, cmdArgs, opts) => {
83
84
  try {
84
85
  const projectId = opts.project
85
86
  ? await resolveProjectId(opts.project)
86
87
  : undefined;
87
88
  const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
89
+ let context;
90
+ if (opts.context) {
91
+ const { validateContext } = await import("./core/context/validate-context.js");
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(opts.context);
95
+ }
96
+ catch {
97
+ console.error("Invalid JSON in --context flag");
98
+ process.exit(1);
99
+ }
100
+ const result = validateContext(parsed);
101
+ if (!result.valid) {
102
+ console.error(`Invalid context: ${result.error}`);
103
+ process.exit(1);
104
+ }
105
+ context = result.context;
106
+ }
88
107
  const built = buildLoopOptions(intervalStr, {
89
108
  ...opts,
90
109
  command: cmdArgs[0],
@@ -93,6 +112,7 @@ program
93
112
  projectId,
94
113
  offset: offsetMs,
95
114
  description: opts.description ?? cmdArgs.join(" "),
115
+ context,
96
116
  });
97
117
  await startLoop(built.options, built.intervalHuman);
98
118
  }
@@ -112,19 +132,43 @@ program
112
132
  .option("--verbose", t("cli.optVerbose"))
113
133
  .option("--cwd <dir>", t("cli.optCwd"))
114
134
  .option("--description <desc>", "Description of the loop")
135
+ .option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
115
136
  .action(async (intervalStr, cmdArgs, opts) => {
116
137
  if (!intervalStr || !cmdArgs || cmdArgs.length === 0) {
117
138
  program.help();
118
139
  return;
119
140
  }
120
141
  const logger = new Logger(opts.verbose ?? false);
142
+ let context;
143
+ if (opts.context) {
144
+ const { validateContext } = await import("./core/context/validate-context.js");
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(opts.context);
148
+ }
149
+ catch {
150
+ console.error("Invalid JSON in --context flag");
151
+ process.exit(1);
152
+ }
153
+ const result = validateContext(parsed);
154
+ if (!result.valid) {
155
+ console.error(`Invalid context: ${result.error}`);
156
+ process.exit(1);
157
+ }
158
+ context = result.context;
159
+ }
121
160
  const built = buildLoopOptions(intervalStr, {
122
161
  ...opts,
123
162
  command: cmdArgs[0],
124
163
  commandArgs: cmdArgs.slice(1),
125
164
  cwd: opts.cwd ?? process.cwd(),
126
165
  description: opts.description ?? cmdArgs.join(" "),
166
+ context,
127
167
  });
168
+ if (built.options.interval === 0) {
169
+ console.error("Manual loops are not supported in foreground mode. Use 'loop-task new manual -- <command>' instead.");
170
+ process.exit(1);
171
+ }
128
172
  const controller = new AbortController();
129
173
  process.on("SIGINT", () => controller.abort());
130
174
  process.on("SIGTERM", () => controller.abort());
@@ -143,8 +187,10 @@ projectCmd
143
187
  .description(t("cli.projectNewDescription"))
144
188
  .argument("<name>", t("cli.projectArgName"))
145
189
  .option("--color <color>", t("cli.optProjectColor"))
190
+ .option("--directory <path>", t("cli.optProjectDirectory"))
191
+ .option("--github-source <owner/repo>", t("cli.optProjectGithubSource"))
146
192
  .action(async (name, opts) => {
147
- await createProjectCli(name, opts.color);
193
+ await createProjectCli(name, opts.color, opts.directory, opts.githubSource);
148
194
  });
149
195
  projectCmd
150
196
  .command("rename")
@@ -177,6 +223,7 @@ program
177
223
  .command("status")
178
224
  .description("Show status of all loops")
179
225
  .option("--json", "Output as JSON")
226
+ .option("--verbose", "Show extra details (skipped, silent-chain counts)")
180
227
  .action(async (opts) => {
181
228
  const { sendRequest } = await import("./client/ipc.js");
182
229
  const response = await sendRequest({ type: "list" });
@@ -191,7 +238,17 @@ program
191
238
  else {
192
239
  for (const loop of loops) {
193
240
  const { describeLoop, statusLabel } = await import("./shared/ui/format.js");
194
- console.log(`${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`);
241
+ let line = `${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`;
242
+ if (opts.verbose) {
243
+ const extra = [];
244
+ if (loop.skippedCount > 0)
245
+ extra.push(`skipped=${loop.skippedCount}`);
246
+ if ((loop.silentChainCount ?? 0) > 0)
247
+ extra.push(`silent=${loop.silentChainCount}`);
248
+ if (extra.length)
249
+ line += ` [${extra.join(", ")}]`;
250
+ }
251
+ console.log(line);
195
252
  }
196
253
  }
197
254
  });
@@ -279,4 +336,33 @@ program
279
336
  console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
280
337
  console.log(` Events: ${baseUrl}/api/events (SSE)`);
281
338
  });
339
+ program
340
+ .command("mcp")
341
+ .description("Show MCP server info and how to connect an MCP client")
342
+ .action(() => {
343
+ const transport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
344
+ const port = process.env.LOOP_CLI_MCP_PORT ?? "8846";
345
+ const sseUrl = `http://${HTTP_API_HOST}:${port}/sse`;
346
+ console.log(`MCP Server`);
347
+ console.log(` Transport: ${transport}`);
348
+ console.log(` SSE URL: ${sseUrl}`);
349
+ console.log("");
350
+ console.log(` Connect an MCP client to the SSE URL above.`);
351
+ console.log("");
352
+ if (transport === "stdio") {
353
+ console.log(` Currently using stdio transport. To switch to SSE (default),`);
354
+ console.log(` restart without LOOP_CLI_MCP_TRANSPORT=stdio.`);
355
+ console.log("");
356
+ }
357
+ console.log(` Example MCP client configs:`);
358
+ console.log("");
359
+ console.log(` OpenCode (opencode.json):`);
360
+ console.log(` { "mcp": { "loop-task": { "type": "remote", "url": "${sseUrl}" } } }`);
361
+ console.log("");
362
+ console.log(` Claude Code (.claude/mcp.json):`);
363
+ console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
364
+ console.log("");
365
+ console.log(` Cursor (.cursor/mcp.json):`);
366
+ console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
367
+ });
282
368
  await program.parseAsync(process.argv);
@@ -23,6 +23,9 @@ export async function startLoop(options, intervalHuman) {
23
23
  console.log(t("cli.startedCommand", { command: options.command }));
24
24
  console.log(t("cli.startedInterval", { interval: intervalHuman }));
25
25
  console.log(t("cli.startedStatus"));
26
+ if (options.interval === 0) {
27
+ console.log(" Note: manual loop use 'trigger' to run on demand");
28
+ }
26
29
  console.log();
27
30
  console.log(t("cli.startedHint"));
28
31
  process.exit(0);
@@ -73,9 +76,13 @@ export async function showStatus(id) {
73
76
  const maxRuns = loop.maxRuns !== null ? String(loop.maxRuns) : t("cli.unlimited");
74
77
  console.log(t("cli.statusTitle", { id: loop.id }));
75
78
  console.log(t("cli.statusCommand", { command: cmd }));
76
- console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: formatDuration(loop.interval) }));
79
+ const intervalDisplay = loop.interval === 0 ? "manual" : formatDuration(loop.interval);
80
+ console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: intervalDisplay }));
77
81
  console.log(t("cli.statusStatus", { status: loop.status }));
78
82
  console.log(t("cli.statusRuns", { runs: loop.runCount, maxRuns }));
83
+ if ((loop.silentChainCount ?? 0) > 0) {
84
+ console.log(t("cli.statusSilentChains", { count: loop.silentChainCount.toLocaleString() }));
85
+ }
79
86
  console.log(t("cli.statusCreated", { created: loop.createdAt }));
80
87
  if (loop.lastRunAt) {
81
88
  const exitInfo = loop.lastExitCode !== null ? t("cli.exitInfo", { code: loop.lastExitCode }) : "";
@@ -70,12 +70,17 @@ export async function listProjectsCli() {
70
70
  process.exit(1);
71
71
  }
72
72
  }
73
- export async function createProjectCli(name, colorInput) {
73
+ export async function createProjectCli(name, colorInput, directory, githubSource) {
74
74
  try {
75
+ if (githubSource !== undefined && githubSource !== "") {
76
+ if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
77
+ throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
78
+ }
79
+ }
75
80
  const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
76
81
  const response = await sendRequest({
77
82
  type: "project-create",
78
- payload: { name, color },
83
+ payload: { name, color, directory, githubSource },
79
84
  });
80
85
  if (response.type !== "ok") {
81
86
  throw new Error(response.message);
@@ -5,7 +5,7 @@ function shellEscape(value) {
5
5
  if (/^[A-Za-z0-9_\-=:./,@]+$/.test(value))
6
6
  return value;
7
7
  // Escape characters that are dangerous inside double quotes, then wrap
8
- // the whole value in single quotesthe strongest shell quoting.
8
+ // the whole value in single quotes the strongest shell quoting.
9
9
  // Single quotes preserve everything literally (newlines, backticks,
10
10
  // $, ", \, parens) except single quotes themselves.
11
11
  return "'" + value.replace(/'/g, "'\\''") + "'";
@@ -0,0 +1,19 @@
1
+ export function validateContext(value) {
2
+ if (value === undefined || value === null) {
3
+ return { valid: true, context: {} };
4
+ }
5
+ if (typeof value !== "object" || Array.isArray(value) || value === null) {
6
+ return { valid: false, error: "Context must be a JSON object" };
7
+ }
8
+ const obj = value;
9
+ for (const key of Object.keys(obj)) {
10
+ const v = obj[key];
11
+ if (Array.isArray(v)) {
12
+ return { valid: false, error: `Context key "${key}" has an array only string values are allowed` };
13
+ }
14
+ if (typeof v === "object" && v !== null) {
15
+ return { valid: false, error: `Context key "${key}" has a nested object only string values are allowed` };
16
+ }
17
+ }
18
+ return { valid: true, context: obj };
19
+ }
@@ -1,11 +1,13 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import { Writable } from "node:stream";
3
4
  import { executeCommand } from "../command/command-runner.js";
4
5
  import { t } from "../../shared/i18n/index.js";
5
6
  import { parseStdout } from "../context/context-parser.js";
6
7
  import { interpolate } from "../context/template.js";
7
8
  export function executeChain(options) {
8
9
  const { chainTargetId, exitCode, task, chainContext, cwd, signal, runCount, logPath, runHistory, logStream, controller } = options;
10
+ const nullStream = new Writable({ write(_chunk, _enc, cb) { cb(); } });
9
11
  if (!chainTargetId) {
10
12
  return Promise.resolve({ runHistory, lastExitCode: exitCode, lastDuration: 0 });
11
13
  }
@@ -23,11 +25,15 @@ export function executeChain(options) {
23
25
  const chainTask = controller.taskResolver(currentTargetId);
24
26
  if (!chainTask)
25
27
  break;
26
- if (logStream) {
28
+ const isSilent = chainTask.silentChain === true;
29
+ if (isSilent) {
30
+ controller.incrementSilentChainCount();
31
+ }
32
+ if (logStream && !isSilent) {
27
33
  logStream.write(t("loop.chainHeader", { name: chainTask.name, branch: prevBranch, prevExit }));
28
34
  }
29
35
  const chainStartedAt = new Date().toISOString();
30
- const chainOffset = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
36
+ const chainOffset = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
31
37
  runHistory.push({
32
38
  runNumber: runCount,
33
39
  startedAt: chainStartedAt,
@@ -39,28 +45,55 @@ export function executeChain(options) {
39
45
  chainGroupId,
40
46
  chainName: chainTask.name,
41
47
  });
42
- const interpolatedCommand = interpolate(chainTask.command, chainContext);
43
- const interpolatedArgs = chainTask.commandArgs.map(a => interpolate(a, chainContext));
44
- const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, cwd, logStream, signal, runCount, true, true);
45
- if (chainResult.stdout) {
46
- const parsed = parseStdout(chainResult.stdout);
47
- if (parsed !== null) {
48
- Object.assign(chainContext, parsed);
48
+ const singleCommandFallback = {
49
+ command: chainTask.command,
50
+ commandArgs: chainTask.commandArgs,
51
+ commandRaw: chainTask.commandRaw,
52
+ };
53
+ const taskSteps = chainTask.steps?.length
54
+ ? chainTask.steps
55
+ : [{ commands: [singleCommandFallback] }];
56
+ const chainTaskHasChains = !!(chainTask.onSuccessTaskId || chainTask.onFailureTaskId);
57
+ const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
58
+ let chainExitCode = 0;
59
+ let chainDuration = 0;
60
+ const effectiveStream = isSilent ? nullStream : (logStream ?? nullStream);
61
+ for (const step of taskSteps) {
62
+ const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, effectiveStream, signal, runCount, shouldCaptureStdout)));
63
+ let stepStdout = "";
64
+ for (const r of stepResults) {
65
+ if (r.status === "fulfilled") {
66
+ chainDuration += r.value.duration;
67
+ if (r.value.stdout)
68
+ stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
69
+ }
70
+ }
71
+ if (shouldCaptureStdout && stepStdout) {
72
+ const parsed = parseStdout(stepStdout);
73
+ if (parsed !== null) {
74
+ Object.assign(chainContext, parsed);
75
+ }
76
+ }
77
+ const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
78
+ if (stepFailure) {
79
+ const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
80
+ chainExitCode = failed ? failed.value.exitCode : 1;
81
+ break;
49
82
  }
50
83
  }
51
- const chainLogSize = fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
84
+ const chainLogSize = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
52
85
  const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
53
86
  if (chainRecord) {
54
- chainRecord.exitCode = chainResult.exitCode;
55
- chainRecord.duration = chainResult.duration;
87
+ chainRecord.exitCode = chainExitCode;
88
+ chainRecord.duration = chainDuration;
56
89
  chainRecord.logSize = Math.max(0, chainLogSize);
57
90
  chainRecord.status = "completed";
58
91
  }
59
- totalExtraDuration += chainResult.duration;
60
- finalExitCode = chainResult.exitCode;
61
- currentTargetId = (chainResult.exitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
62
- prevBranch = chainResult.exitCode === 0 ? "onSuccess" : "onFailure";
63
- prevExit = chainResult.exitCode;
92
+ totalExtraDuration += chainDuration;
93
+ finalExitCode = chainExitCode;
94
+ currentTargetId = (chainExitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
95
+ prevBranch = chainExitCode === 0 ? "onSuccess" : "onFailure";
96
+ prevExit = chainExitCode;
64
97
  }
65
98
  return { runHistory, lastExitCode: finalExitCode, lastDuration: totalExtraDuration };
66
99
  })();
@@ -27,6 +27,7 @@ export class LoopController extends EventEmitter {
27
27
  this.runHistory = [];
28
28
  this.currentRunStartOffset = 0;
29
29
  this.skippedCount = 0;
30
+ this._silentChainCount = 0;
30
31
  this.id = id;
31
32
  this.options = options;
32
33
  this.logPath = logPath;
@@ -46,10 +47,17 @@ export class LoopController extends EventEmitter {
46
47
  this._paused = state?.status === "paused" || state?.status === "idle";
47
48
  this.runHistory = (state?.runHistory ?? []).map((r) => r.status === "running" ? { ...r, status: "completed" } : r).map((r) => ({ ...r, logOffset: r.logOffset ?? 0 }));
48
49
  this.skippedCount = state?.skippedCount ?? 0;
50
+ this._silentChainCount = state?.silentChainCount ?? 0;
49
51
  }
50
52
  get status() {
51
53
  return this._status;
52
54
  }
55
+ get silentChainCount() {
56
+ return this._silentChainCount;
57
+ }
58
+ incrementSilentChainCount() {
59
+ this._silentChainCount += 1;
60
+ }
53
61
  start() {
54
62
  if (this._loopActive)
55
63
  return;
@@ -58,6 +66,13 @@ export class LoopController extends EventEmitter {
58
66
  this.logStream?.end();
59
67
  this.abortController = new AbortController();
60
68
  this.logStream = fs.createWriteStream(this.logPath, { flags: "a" });
69
+ if (this.options.interval === 0 && !this._stopAfterRun) {
70
+ this._status = "idle";
71
+ this.nextRunAt = null;
72
+ this._loopActive = false;
73
+ this.emit("stopped");
74
+ return;
75
+ }
61
76
  this.loopPromise = this.run().finally(() => { this._loopActive = false; });
62
77
  if (this.sessionStartedAt === null) {
63
78
  this.sessionStartedAt = new Date().toISOString();
@@ -112,6 +127,8 @@ export class LoopController extends EventEmitter {
112
127
  this._maxRunsReached = true;
113
128
  return false;
114
129
  }
130
+ if (this.options.interval === 0)
131
+ return false;
115
132
  this.sessionStartedAt = new Date().toISOString();
116
133
  this._resetSchedule = true;
117
134
  this._paused = false;
@@ -176,6 +193,7 @@ export class LoopController extends EventEmitter {
176
193
  remainingDelayMs: this.remainingDelayMs,
177
194
  runHistory: this.runHistory,
178
195
  skippedCount: this.skippedCount,
196
+ silentChainCount: this._silentChainCount,
179
197
  };
180
198
  }
181
199
  clearMaxRunsReached() {
@@ -106,6 +106,14 @@ export async function runLoop(ctrl) {
106
106
  }
107
107
  }
108
108
  else {
109
+ if (ctrl.options.interval === 0) {
110
+ ctrl._paused = true;
111
+ ctrl._status = "idle";
112
+ ctrl.remainingDelayMs = null;
113
+ ctrl.nextRunAt = null;
114
+ ctrl.emit("stopped");
115
+ return;
116
+ }
109
117
  const nextSlotMs = runStartedAtMs + ctrl.options.interval;
110
118
  const overrunMs = Date.now() - nextSlotMs;
111
119
  if (overrunMs >= 0) {
@@ -28,7 +28,7 @@ export async function executeRunImpl(ctrl, signal) {
28
28
  ctrl.runAbortController = new AbortController();
29
29
  const task = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
30
30
  const cwd = resolveEffectiveCwd(ctrl.options.cwd, ctrl.projectDirectory);
31
- const chainContext = {};
31
+ const chainContext = { ...(task?.context ?? {}), ...(ctrl.options.context ?? {}) };
32
32
  const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
33
33
  const singleCommandFallback = {
34
34
  command: task?.command ?? ctrl.options.command,
@@ -1,4 +1,6 @@
1
1
  export function computePhase(loopId, intervalMs) {
2
+ if (intervalMs <= 0)
3
+ return 0;
2
4
  let hash = 0;
3
5
  for (let i = 0; i < loopId.length; i++) {
4
6
  hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
@@ -6,6 +8,8 @@ export function computePhase(loopId, intervalMs) {
6
8
  return Math.abs(hash) % intervalMs;
7
9
  }
8
10
  export function alignToPhase(now, intervalMs, phaseMs) {
11
+ if (intervalMs <= 0)
12
+ return 0;
9
13
  const elapsed = now % intervalMs;
10
14
  const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
11
15
  return delay;
@@ -10,10 +10,21 @@ export function buildOpenApiSpec() {
10
10
  servers: [
11
11
  { url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
12
12
  ],
13
+ tags: [
14
+ { name: "Loops", description: "Loop management" },
15
+ { name: "Tasks", description: "Task management" },
16
+ { name: "Task Chains", description: "Create chained tasks" },
17
+ { name: "Projects", description: "Project management" },
18
+ { name: "Logs", description: "Log retrieval" },
19
+ { name: "Events", description: "Server-sent events" },
20
+ { name: "Settings", description: "Daemon settings" },
21
+ { name: "MCP", description: "Model Context Protocol server" },
22
+ { name: "Docs", description: "API documentation" },
23
+ ],
13
24
  paths: {
14
25
  "/api/loops": {
15
26
  get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
16
- post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
27
+ post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m", description: "Interval like 30s, 5m, 1h, or 'manual' for trigger-only loops" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
17
28
  },
18
29
  "/api/loops/{id}": {
19
30
  get: { summary: "Get loop status", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Loop details" }, "404": { description: "Not found" } } },
@@ -22,33 +33,49 @@ export function buildOpenApiSpec() {
22
33
  },
23
34
  "/api/loops/{id}/pause": { post: { summary: "Pause a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Paused" }, "404": { description: "Not found" } } } },
24
35
  "/api/loops/{id}/resume": { post: { summary: "Resume a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Resumed" }, "404": { description: "Not found" } } } },
36
+ "/api/loops/{id}/play": { post: { summary: "Play (start) a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Playing", content: { "application/json": { schema: { type: "object" } } } }, "404": { description: "Not found" }, "409": { description: "Already running or max runs blocked" } } } },
25
37
  "/api/loops/{id}/trigger": { post: { summary: "Trigger a loop now", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Triggered" }, "404": { description: "Not found" }, "400": { description: "Max runs reached" }, "409": { description: "Already running" } } } },
26
38
  "/api/loops/{id}/stop": { post: { summary: "Stop a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Stopped" }, "404": { description: "Not found" } } } },
27
39
  "/api/loops/stop-all": { post: { summary: "Stop all loops", tags: ["Loops"], responses: { "200": { description: "All stopped", content: { "application/json": { schema: { type: "object", properties: { count: { type: "integer" } } } } } } } } },
40
+ "/api/loops/{id}/runs": { get: { summary: "List run history", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", schema: { type: "string", description: "ISO 8601 date" } }, { name: "to", in: "query", schema: { type: "string", description: "ISO 8601 date" } }], responses: { "200": { description: "Run records", content: { "application/json": { schema: { type: "array" } } } }, "404": { description: "Not found" } } } },
41
+ "/api/loops/{id}/logs/date": { get: { summary: "Date-filtered logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", required: true, schema: { type: "string", description: "ISO 8601 start date" } }, { name: "to", in: "query", required: true, schema: { type: "string", description: "ISO 8601 end date" } }], responses: { "200": { description: "Filtered log content" }, "404": { description: "Not found" }, "400": { description: "Missing or invalid date params" } } } },
28
42
  "/api/loops/{id}/logs": { get: { summary: "Fetch loop logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer", default: 50 } }], responses: { "200": { description: "Log content" }, "404": { description: "Not found" } } } },
29
43
  "/api/loops/{id}/logs/stream": { get: { summary: "Stream logs via SSE", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer" } }], responses: { "200": { description: "SSE stream" }, "404": { description: "Not found" } } } },
30
44
  "/api/loops/{id}/runs/{num}": { get: { summary: "Get run-specific log", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "num", in: "path", required: true, schema: { type: "integer" } }], responses: { "200": { description: "Run log content" } } } },
31
45
  "/api/tasks": {
32
46
  get: { summary: "List all tasks", tags: ["Tasks"], responses: { "200": { description: "Array of tasks" } } },
33
- post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
47
+ post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
34
48
  },
35
49
  "/api/tasks/{id}": {
36
50
  get: { summary: "Get a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Task details" }, "404": { description: "Not found" } } },
37
51
  patch: { summary: "Update a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object" } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" } } },
38
52
  delete: { summary: "Delete a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "404": { description: "Not found" } } },
39
53
  },
54
+ "/api/task-chains": { post: { summary: "Create a chain of tasks", tags: ["Task Chains"], requestBody: { content: { "application/json": { schema: { type: "object", required: ["tasks"], properties: { tasks: { type: "array", items: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } }, chain: { type: "string", enum: ["sequential-success", "sequential-failure", "none"], description: "How to wire tasks together" } } } } } }, responses: { "201": { description: "Chain created", content: { "application/json": { schema: { type: "object", properties: { taskIds: { type: "array", items: { type: "string" } } } } } } }, "400": { description: "Validation error or rollback" } } } },
40
55
  "/api/projects": {
41
56
  get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
42
- post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
57
+ post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
43
58
  },
44
59
  "/api/projects/{id}": {
45
- patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
60
+ patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
46
61
  delete: { summary: "Delete a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "400": { description: "Cannot delete system project" } } },
47
62
  },
63
+ "/api/settings": { get: { summary: "Get daemon settings", tags: ["Settings"], responses: { "200": { description: "Current settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } } } }, patch: { summary: "Update daemon settings", tags: ["Settings"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, responses: { "200": { description: "Updated settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, "400": { description: "Validation error" } } } },
48
64
  "/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
49
65
  "/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
50
66
  "/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
51
67
  },
68
+ components: {
69
+ schemas: {
70
+ DaemonSettings: {
71
+ type: "object",
72
+ properties: {
73
+ httpApiEnabled: { type: "boolean", description: "Whether the HTTP API server is enabled" },
74
+ mcpApiEnabled: { type: "boolean", description: "Whether the MCP server is enabled" },
75
+ },
76
+ },
77
+ },
78
+ },
52
79
  };
53
80
  }
54
81
  export function buildSwaggerHtml() {