loop-task 2.1.12 → 2.1.14

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 (58) hide show
  1. package/README.md +27 -9
  2. package/dist/app/App.js +3 -1
  3. package/dist/cli.js +89 -2
  4. package/dist/client/commands.js +4 -1
  5. package/dist/client/project-commands.js +7 -2
  6. package/dist/core/context/template.js +1 -1
  7. package/dist/core/context/validate-context.js +2 -2
  8. package/dist/core/loop/chain-executor.js +11 -4
  9. package/dist/core/loop/loop-controller.js +9 -0
  10. package/dist/daemon/http/openapi.js +30 -3
  11. package/dist/daemon/http/route-loops.js +85 -0
  12. package/dist/daemon/http/route-misc.js +16 -0
  13. package/dist/daemon/http/route-projects.js +2 -2
  14. package/dist/daemon/http/route-tasks.js +58 -0
  15. package/dist/daemon/http/routes.js +3 -2
  16. package/dist/daemon/http/server.js +20 -2
  17. package/dist/daemon/index.js +69 -7
  18. package/dist/daemon/managers/loop-entry.js +2 -0
  19. package/dist/daemon/managers/loop-manager.js +4 -4
  20. package/dist/daemon/managers/project-manager.js +16 -2
  21. package/dist/daemon/mcp/index.js +2 -0
  22. package/dist/daemon/mcp/openapi-sync.js +50 -0
  23. package/dist/daemon/mcp/server.js +167 -0
  24. package/dist/daemon/mcp/tools.js +368 -0
  25. package/dist/daemon/server/handlers/index.js +8 -1
  26. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  27. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  28. package/dist/daemon/server/index.js +3 -1
  29. package/dist/daemon/settings-manager.js +54 -0
  30. package/dist/entities/tasks/filters.js +1 -1
  31. package/dist/features/commands/commands.js +4 -2
  32. package/dist/features/commands/useCommandHandlers.js +32 -1
  33. package/dist/features/commands/useGlobalShortcuts.js +0 -2
  34. package/dist/features/overlays/ExportModal.js +1 -1
  35. package/dist/loop-config.js +1 -1
  36. package/dist/shared/clipboard.js +2 -2
  37. package/dist/shared/config/paths.js +3 -0
  38. package/dist/shared/container/index.js +2 -0
  39. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  40. package/dist/shared/hooks/useUndoRedo.js +1 -1
  41. package/dist/shared/i18n/en.json +25 -3
  42. package/dist/shared/services/project-service.js +4 -4
  43. package/dist/shared/services/settings-service.js +49 -0
  44. package/dist/shared/services/types.js +1 -0
  45. package/dist/shared/ui/FocusableInput.js +1 -1
  46. package/dist/shared/ui/SelectModal.js +1 -1
  47. package/dist/shared/utils/syntax.js +3 -3
  48. package/dist/widgets/header/Header.js +15 -2
  49. package/dist/widgets/left-panel/Navigator.js +3 -2
  50. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  51. package/dist/widgets/log-modal/LogModal.js +1 -1
  52. package/dist/widgets/loop-form/WizardForm.js +2 -2
  53. package/dist/widgets/loop-form/useHandleComplete.js +4 -1
  54. package/dist/widgets/project-form/ProjectForm.js +11 -2
  55. package/dist/widgets/right-panel/Inspector.js +1 -1
  56. package/dist/widgets/right-panel/RightPanel.js +1 -1
  57. package/dist/widgets/task-form/TaskForm.js +24 -4
  58. package/package.json +4 -2
package/README.md CHANGED
@@ -145,6 +145,7 @@ Colors can be a name (`white`, `cyan`, `green`, `yellow`, `orange`, `pink`) or a
145
145
  | `loop-task export [file]` | Export all configs to JSON file (or stdout) |
146
146
  | `loop-task import <file>` | Import configs from file (triggers hot-reload) |
147
147
  | `loop-task api` | Show HTTP API endpoints (base URL, Swagger UI, OpenAPI spec) |
148
+ | `loop-task http-host [address]` | Show or set the HTTP API bind host (default `0.0.0.0`; `local` to lock to loopback) |
148
149
  | `loop-task project list` | List all projects |
149
150
  | `loop-task project new <name> [--color <color>]` | Create a project |
150
151
  | `loop-task project rename <id\|name> <new-name>` | Rename a project |
@@ -215,7 +216,7 @@ Destructive actions (pause, force run, delete) prompt a confirmation before exec
215
216
 
216
217
  ### Copy & paste in the command bar
217
218
 
218
- The bottom command bar is a normal terminal input, so use your terminal's own clipboard gestures they work in every terminal (including the VS Code integrated terminal, where Ctrl+C/V are captured by the editor):
219
+ The bottom command bar is a normal terminal input, so use your terminal's own clipboard gestures, they work in every terminal (including the VS Code integrated terminal, where Ctrl+C/V are captured by the editor):
219
220
 
220
221
  - **Paste** with **Ctrl+Shift+V** (Windows/Linux), **Cmd+V** (macOS), or **right-click**. Multi-line pastes collapse to a single line.
221
222
  - **Ctrl+U** clears the command bar (select-all + delete).
@@ -395,7 +396,7 @@ docker run -v ~/.loop-cli:/root/.loop-cli loop-task new 30m -- npm test
395
396
 
396
397
  ## HTTP API
397
398
 
398
- The daemon exposes a REST + SSE API on `localhost:8845` (configurable via `LOOP_CLI_HTTP_PORT`). It starts automatically with the daemon no extra flags needed.
399
+ The daemon exposes a REST + SSE API on `localhost:8845` (configurable via `LOOP_CLI_HTTP_PORT`). It starts automatically with the daemon, no extra flags needed.
399
400
 
400
401
  ### Quick reference
401
402
 
@@ -439,8 +440,8 @@ curl http://127.0.0.1:8845/api/projects
439
440
 
440
441
  ### From the CLI/TUI
441
442
 
442
- - `loop-task api` prints all API endpoints to stdout
443
- - Board: press **Ctrl+G** or type `api` shows a toast with API info
443
+ - `loop-task api`, prints all API endpoints to stdout
444
+ - Board: press **Ctrl+G** or type `api`, shows a toast with API info
444
445
 
445
446
  ### Response format
446
447
 
@@ -451,7 +452,24 @@ All responses use a consistent JSON envelope:
451
452
  {"ok": false, "error": {"message": "..."}} // error (400/404/405/500)
452
453
  ```
453
454
 
454
- The API binds to `127.0.0.1` only — it is not reachable from the network. If the port is already in use, the daemon skips the HTTP server and continues with IPC only.
455
+ If the port is already in use, the daemon skips the HTTP server and continues with IPC only.
456
+
457
+ ### Network access (remote / VMs)
458
+
459
+ The HTTP API (8845) and MCP server (8846) bind to **`0.0.0.0` (all interfaces) by default**, so a daemon on a VM or homelab box is reachable f, other machines out of the box — connect over SSH, [Tailscale](https://tailscale.com), or your LAN. `loop-task http-host` governs the bind for both.
460
+
461
+ > **The HTTP API is unauthenticated.** Anything that can reach the bound port can create and trigger loops, i.e. run commands on the host. Because the default is `0.0.0.0`, **securing access is your responsibility at the network layer**: run the box behind a VPN (Tailscale), reach it only via an SSH tunnel, and/or block the port at the cloud firewall so it isn't exposed to the public internet.
462
+
463
+ Change the bind host at any time (persisted in daemon settings, not an env var, and the daemon **rebinds live**, no restart):
464
+
465
+ ```bash
466
+ loop-task http-host # show the current bind host + reachable URL
467
+ loop-task http-host local # 127.0.0.1, loopback only (lock it down)
468
+ loop-task http-host 100.99.155.102 # bind a single interface (e.g. a Tailscale IP)
469
+ loop-task http-host all # 0.0.0.0, all interfaces (the default)
470
+ ```
471
+
472
+ **Locking it down:** if the box isn't behind a firewall/VPN you trust, either set `loop-task http-host local` and reach it through an SSH tunnel (`ssh -L 8845:127.0.0.1:8845 <host>`), or expose it tailnet-only with `tailscale serve --bg 8845` (HTTPS at `https://<node>.<tailnet>.ts.net`, restrict further with Tailscale ACLs; never `tailscale funnel`).
455
473
 
456
474
  ## Development
457
475
 
@@ -481,11 +499,11 @@ pnpm run build # tsc -p tsconfig.build.json
481
499
 
482
500
  ### Testing the board in a browser (ttyd)
483
501
 
484
- > **Agents: Do NOT use ttyd unless the user explicitly asks you to check the CLI in a browser.** It is never the default. Do not start a ttyd server on your own for "manual pass" tasks or visual QA those are for the human. Reach for ttyd only when the user says "check the board in the browser", "use ttyd", or similar.
502
+ > **Agents: Do NOT use ttyd unless the user explicitly asks you to check the CLI in a browser.** It is never the default. Do not start a ttyd server on your own for "manual pass" tasks or visual QA, those are for the human. Reach for ttyd only when the user says "check the board in the browser", "use ttyd", or similar.
485
503
 
486
- The board is an interactive TUI, so it needs a real terminal you can't drive it from a piped/captured shell (and neither can an AI agent). [`ttyd`](https://github.com/tsl0922/ttyd) shares a terminal over HTTP, which makes the board reachable from a browser and scriptable by browser-automation agents but only when explicitly requested.
504
+ The board is an interactive TUI, so it needs a real terminal, you can't drive it from a piped/captured shell (and neither can an AI agent). [`ttyd`](https://github.com/tsl0922/ttyd) shares a terminal over HTTP, which makes the board reachable from a browser and scriptable by browser-automation agents, but only when explicitly requested.
487
505
 
488
- Install ttyd (see the [ttyd README](https://github.com/tsl0922/ttyd#installation) e.g. `winget install tsl0922.ttyd`, `brew install ttyd`, or `apt install ttyd`), then serve the board from an interactive terminal:
506
+ Install ttyd (see the [ttyd README](https://github.com/tsl0922/ttyd#installation), e.g. `winget install tsl0922.ttyd`, `brew install ttyd`, or `apt install ttyd`), then serve the board from an interactive terminal:
489
507
 
490
508
  ```bash
491
509
  # Point -w at the repo (absolute path) and run the dev board:
@@ -497,7 +515,7 @@ ttyd -W -w "C:\Projects\Personal\loop-cli" -p 7681 node dist/entry.js
497
515
 
498
516
  Open `http://localhost:7681` in a browser and use the board as normal. `-W` makes it writable so keystrokes reach the TUI. Handy for demos, for testing on a machine without a good local terminal, and for letting an AI agent drive the board (navigate, send keys, screenshot; ttyd renders via xterm.js on a `<canvas>`, so read state from screenshots, not page text).
499
517
 
500
- > **Windows note:** always pass `-w "<absolute repo path>"`. Without it, ttyd gives the spawned command no valid working directory and it fails with `CreateProcessW failed with error 267` for *every* command (`pnpm`, `npx`, `node` all fail the same way; it is not a `.cmd`-shim issue). On macOS/Linux `-w` is optional but harmless. Start ttyd from a real interactive terminal; a detached/console-less launch can crash its ConPTY on Windows.
518
+ > **Windows note:** always pass `-w "<absolute repo path>"`. Without it, ttyd gives the spawned command no valid working directory and it fails with `CreateProcessW failed with error 267`, for *every* command (`pnpm`, `npx`, `node` all fail the same way; it is not a `.cmd`-shim issue). On macOS/Linux `-w` is optional but harmless. Start ttyd from a real interactive terminal; a detached/console-less launch can crash its ConPTY on Windows.
501
519
 
502
520
  ## License
503
521
 
package/dist/app/App.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useCallback } from "react";
3
3
  import { Box, useApp } from "ink";
4
4
  import { useLoopPolling } from "../shared/hooks/useLoopPolling.js";
5
+ import { useDaemonSettings } from "../shared/hooks/useDaemonSettings.js";
5
6
  import { useLogStream } from "../shared/hooks/useLogStream.js";
6
7
  import { useBreakpoint } from "../shared/hooks/useBreakpoint.js";
7
8
  import { useToasts } from "../shared/ui/Toast.js";
@@ -42,6 +43,7 @@ export function App(props) {
42
43
  const logService = useInject(TYPES.LogService);
43
44
  const exportService = useInject(TYPES.ExportService);
44
45
  const { loops, daemonStatus, refresh } = useLoopPolling();
46
+ const daemonSettings = useDaemonSettings();
45
47
  const { view, push, pop } = useRouter("board");
46
48
  const { toasts, push: pushToast } = useToasts();
47
49
  const breakpoint = useBreakpoint();
@@ -113,7 +115,7 @@ export function App(props) {
113
115
  pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
114
116
  };
115
117
  const commandContext = useMemo(() => ({ activeTab: s.activeTab, selectedLoop: s.selected, selectedTask: s.selectedTask, selectedProject: s.selectedProjectEntity }), [s.activeTab, s.selected, s.selectedTask]);
116
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
118
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, httpApiEnabled: daemonSettings.reachable ? daemonSettings.httpApiEnabled : undefined, mcpApiEnabled: daemonSettings.reachable ? daemonSettings.mcpApiEnabled : undefined, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
117
119
  handleCommand("edit"); }, onProjectDelete: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
118
120
  handleCommand("delete"); } }), s.debugMode ? _jsx(DebugPanel, { entries: s.debugEntries }) : null] })) : (_jsx(FormRouter, { view: view, editTarget: s.editTarget, cloneMode: s.cloneMode, editTask: s.editTask, editProject: s.editProject, pendingTaskSelection: s.pendingTaskSelection, tasks: s.tasks, projects: s.projects, currentProjectId: s.currentProjectId, cancelCreate: cancelCreate, onCreateDone: onCreateDone, handleChooseTask: handleChooseTask, cancelTask: cancelTask, onTaskDone: onTaskDone, cancelProject: cancelProject, onProjectDone: onProjectDone })) }, viewKey(view, s.editTarget, s.editTask)), isBoardView(view) ? (_jsx(CommandInput, { context: commandContext, onCommand: handleCommand, confirmState: s.confirmState, searchState: s.searchState, searchValue: s.searchValue, onSearchChange: s.handleSearchChange, onSearchSubmit: s.handleSearchSubmit, onSearchCancel: s.handleSearchCancel, onConfirmYes: handleConfirmYes, onConfirmCancel: handleConfirmCancel, onCopy: handleContextualCopy, onPanelAction: triggerContextualAction, disabled: commandInputDisabled, navOwner: inputOwner, onInputStateChange: (hasText, dropdownOpen) => { s.setCommandBarHasText(hasText); s.setCommandBarDropdownOpen(dropdownOpen); } })) : null, _jsx(OverlayStack, { commandsBrowserOpen: s.commandsBrowserOpen, commandContext: commandContext, onCommandsBrowserClose: () => s.setCommandsBrowserOpen(false), onCommandsBrowserExecute: (v) => { s.setCommandsBrowserOpen(false); handleCommand(v); }, contextHelpOpen: s.contextHelpOpen, onContextHelpClose: () => s.setContextHelpOpen(false), exportModal: s.exportModal, onExportModalClose: () => s.setExportModal(null), onExportCopy: () => pushToast("success", t("board.toastCopied")), logModalRun: s.logModalRun, logModalLoopId: s.logModalLoopId, logModalLines: s.logModalLines, logModalLoading: s.logModalLoading, onLogModalClose: () => { s.setLogModalRun(null); s.setLogModalLoopId(null); }, onLogCopy: () => pushToast("success", t("board.toastCopied")), toasts: toasts })] }));
119
121
  }
package/dist/cli.js CHANGED
@@ -187,8 +187,10 @@ projectCmd
187
187
  .description(t("cli.projectNewDescription"))
188
188
  .argument("<name>", t("cli.projectArgName"))
189
189
  .option("--color <color>", t("cli.optProjectColor"))
190
+ .option("--directory <path>", t("cli.optProjectDirectory"))
191
+ .option("--github-source <owner/repo>", t("cli.optProjectGithubSource"))
190
192
  .action(async (name, opts) => {
191
- await createProjectCli(name, opts.color);
193
+ await createProjectCli(name, opts.color, opts.directory, opts.githubSource);
192
194
  });
193
195
  projectCmd
194
196
  .command("rename")
@@ -221,6 +223,7 @@ program
221
223
  .command("status")
222
224
  .description("Show status of all loops")
223
225
  .option("--json", "Output as JSON")
226
+ .option("--verbose", "Show extra details (skipped, silent-chain counts)")
224
227
  .action(async (opts) => {
225
228
  const { sendRequest } = await import("./client/ipc.js");
226
229
  const response = await sendRequest({ type: "list" });
@@ -235,7 +238,17 @@ program
235
238
  else {
236
239
  for (const loop of loops) {
237
240
  const { describeLoop, statusLabel } = await import("./shared/ui/format.js");
238
- console.log(`${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`);
241
+ let line = `${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`;
242
+ if (opts.verbose) {
243
+ const extra = [];
244
+ if (loop.skippedCount > 0)
245
+ extra.push(`skipped=${loop.skippedCount}`);
246
+ if ((loop.silentChainCount ?? 0) > 0)
247
+ extra.push(`silent=${loop.silentChainCount}`);
248
+ if (extra.length)
249
+ line += ` [${extra.join(", ")}]`;
250
+ }
251
+ console.log(line);
239
252
  }
240
253
  }
241
254
  });
@@ -323,4 +336,78 @@ program
323
336
  console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
324
337
  console.log(` Events: ${baseUrl}/api/events (SSE)`);
325
338
  });
339
+ program
340
+ .command("http-host")
341
+ .description("Show or set the network interface the HTTP API + MCP server bind to (default 0.0.0.0)")
342
+ .argument("[address]", "IP to bind — or 'local' (127.0.0.1) / 'all' (0.0.0.0)")
343
+ .action(async (address) => {
344
+ const { ensureDaemon } = await import("./daemon/spawner/index.js");
345
+ const { sendRequest } = await import("./client/ipc.js");
346
+ const port = process.env.LOOP_CLI_HTTP_PORT ?? String(HTTP_API_PORT);
347
+ try {
348
+ ensureDaemon();
349
+ if (!address) {
350
+ const res = await sendRequest({ type: "settings-get" });
351
+ const host = res.type === "ok" ? res.data.httpApiHost : HTTP_API_HOST;
352
+ const shown = host === "0.0.0.0" ? "<this-machine-ip>" : host;
353
+ const mcpPort = process.env.LOOP_CLI_MCP_PORT ?? "8846";
354
+ console.log(`API bind host: ${host}`);
355
+ console.log(` HTTP API: http://${shown}:${port}`);
356
+ console.log(` MCP SSE: http://${shown}:${mcpPort}/sse`);
357
+ return;
358
+ }
359
+ const normalized = address === "local" || address === "localhost"
360
+ ? "127.0.0.1"
361
+ : address === "all" || address === "any"
362
+ ? "0.0.0.0"
363
+ : address;
364
+ const res = await sendRequest({ type: "settings-set", settings: { httpApiHost: normalized } });
365
+ if (res.type !== "ok") {
366
+ console.error(res.type === "error" ? res.message : "Failed to update setting");
367
+ process.exit(1);
368
+ }
369
+ console.log(`HTTP API + MCP server now binding to ${normalized}`);
370
+ if (normalized !== "127.0.0.1") {
371
+ console.log("");
372
+ console.log("Note: the API is unauthenticated — anything that can reach this");
373
+ console.log("interface can create and trigger loops. Secure access at the network");
374
+ console.log("layer (SSH/Tailscale/firewall). Use `loop-task http-host local` for");
375
+ console.log("loopback-only.");
376
+ }
377
+ }
378
+ catch (error) {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ console.error(t("cli.error", { message }));
381
+ process.exit(1);
382
+ }
383
+ });
384
+ program
385
+ .command("mcp")
386
+ .description("Show MCP server info and how to connect an MCP client")
387
+ .action(() => {
388
+ const transport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
389
+ const port = process.env.LOOP_CLI_MCP_PORT ?? "8846";
390
+ const sseUrl = `http://${HTTP_API_HOST}:${port}/sse`;
391
+ console.log(`MCP Server`);
392
+ console.log(` Transport: ${transport}`);
393
+ console.log(` SSE URL: ${sseUrl}`);
394
+ console.log("");
395
+ console.log(` Connect an MCP client to the SSE URL above.`);
396
+ console.log("");
397
+ if (transport === "stdio") {
398
+ console.log(` Currently using stdio transport. To switch to SSE (default),`);
399
+ console.log(` restart without LOOP_CLI_MCP_TRANSPORT=stdio.`);
400
+ console.log("");
401
+ }
402
+ console.log(` Example MCP client configs:`);
403
+ console.log("");
404
+ console.log(` OpenCode (opencode.json):`);
405
+ console.log(` { "mcp": { "loop-task": { "type": "remote", "url": "${sseUrl}" } } }`);
406
+ console.log("");
407
+ console.log(` Claude Code (.claude/mcp.json):`);
408
+ console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
409
+ console.log("");
410
+ console.log(` Cursor (.cursor/mcp.json):`);
411
+ console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
412
+ });
326
413
  await program.parseAsync(process.argv);
@@ -24,7 +24,7 @@ export async function startLoop(options, intervalHuman) {
24
24
  console.log(t("cli.startedInterval", { interval: intervalHuman }));
25
25
  console.log(t("cli.startedStatus"));
26
26
  if (options.interval === 0) {
27
- console.log(" Note: manual loopuse 'trigger' to run on demand");
27
+ console.log(" Note: manual loop use 'trigger' to run on demand");
28
28
  }
29
29
  console.log();
30
30
  console.log(t("cli.startedHint"));
@@ -80,6 +80,9 @@ export async function showStatus(id) {
80
80
  console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: intervalDisplay }));
81
81
  console.log(t("cli.statusStatus", { status: loop.status }));
82
82
  console.log(t("cli.statusRuns", { runs: loop.runCount, maxRuns }));
83
+ if ((loop.silentChainCount ?? 0) > 0) {
84
+ console.log(t("cli.statusSilentChains", { count: loop.silentChainCount.toLocaleString() }));
85
+ }
83
86
  console.log(t("cli.statusCreated", { created: loop.createdAt }));
84
87
  if (loop.lastRunAt) {
85
88
  const exitInfo = loop.lastExitCode !== null ? t("cli.exitInfo", { code: loop.lastExitCode }) : "";
@@ -70,12 +70,17 @@ export async function listProjectsCli() {
70
70
  process.exit(1);
71
71
  }
72
72
  }
73
- export async function createProjectCli(name, colorInput) {
73
+ export async function createProjectCli(name, colorInput, directory, githubSource) {
74
74
  try {
75
+ if (githubSource !== undefined && githubSource !== "") {
76
+ if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
77
+ throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
78
+ }
79
+ }
75
80
  const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
76
81
  const response = await sendRequest({
77
82
  type: "project-create",
78
- payload: { name, color },
83
+ payload: { name, color, directory, githubSource },
79
84
  });
80
85
  if (response.type !== "ok") {
81
86
  throw new Error(response.message);
@@ -5,7 +5,7 @@ function shellEscape(value) {
5
5
  if (/^[A-Za-z0-9_\-=:./,@]+$/.test(value))
6
6
  return value;
7
7
  // Escape characters that are dangerous inside double quotes, then wrap
8
- // the whole value in single quotesthe strongest shell quoting.
8
+ // the whole value in single quotes the strongest shell quoting.
9
9
  // Single quotes preserve everything literally (newlines, backticks,
10
10
  // $, ", \, parens) except single quotes themselves.
11
11
  return "'" + value.replace(/'/g, "'\\''") + "'";
@@ -9,10 +9,10 @@ export function validateContext(value) {
9
9
  for (const key of Object.keys(obj)) {
10
10
  const v = obj[key];
11
11
  if (Array.isArray(v)) {
12
- return { valid: false, error: `Context key "${key}" has an arrayonly string values are allowed` };
12
+ return { valid: false, error: `Context key "${key}" has an array only string values are allowed` };
13
13
  }
14
14
  if (typeof v === "object" && v !== null) {
15
- return { valid: false, error: `Context key "${key}" has a nested objectonly string values are allowed` };
15
+ return { valid: false, error: `Context key "${key}" has a nested object only string values are allowed` };
16
16
  }
17
17
  }
18
18
  return { valid: true, context: obj };
@@ -1,11 +1,13 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import { Writable } from "node:stream";
3
4
  import { executeCommand } from "../command/command-runner.js";
4
5
  import { t } from "../../shared/i18n/index.js";
5
6
  import { parseStdout } from "../context/context-parser.js";
6
7
  import { interpolate } from "../context/template.js";
7
8
  export function executeChain(options) {
8
9
  const { chainTargetId, exitCode, task, chainContext, cwd, signal, runCount, logPath, runHistory, logStream, controller } = options;
10
+ const nullStream = new Writable({ write(_chunk, _enc, cb) { cb(); } });
9
11
  if (!chainTargetId) {
10
12
  return Promise.resolve({ runHistory, lastExitCode: exitCode, lastDuration: 0 });
11
13
  }
@@ -23,11 +25,15 @@ export function executeChain(options) {
23
25
  const chainTask = controller.taskResolver(currentTargetId);
24
26
  if (!chainTask)
25
27
  break;
26
- if (logStream) {
28
+ const isSilent = chainTask.silentChain === true;
29
+ if (isSilent) {
30
+ controller.incrementSilentChainCount();
31
+ }
32
+ if (logStream && !isSilent) {
27
33
  logStream.write(t("loop.chainHeader", { name: chainTask.name, branch: prevBranch, prevExit }));
28
34
  }
29
35
  const chainStartedAt = new Date().toISOString();
30
- const chainOffset = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
36
+ const chainOffset = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
31
37
  runHistory.push({
32
38
  runNumber: runCount,
33
39
  startedAt: chainStartedAt,
@@ -51,8 +57,9 @@ export function executeChain(options) {
51
57
  const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
52
58
  let chainExitCode = 0;
53
59
  let chainDuration = 0;
60
+ const effectiveStream = isSilent ? nullStream : (logStream ?? nullStream);
54
61
  for (const step of taskSteps) {
55
- const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, logStream, signal, runCount, shouldCaptureStdout)));
62
+ const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, effectiveStream, signal, runCount, shouldCaptureStdout)));
56
63
  let stepStdout = "";
57
64
  for (const r of stepResults) {
58
65
  if (r.status === "fulfilled") {
@@ -74,7 +81,7 @@ export function executeChain(options) {
74
81
  break;
75
82
  }
76
83
  }
77
- const chainLogSize = fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
84
+ const chainLogSize = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
78
85
  const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
79
86
  if (chainRecord) {
80
87
  chainRecord.exitCode = chainExitCode;
@@ -27,6 +27,7 @@ export class LoopController extends EventEmitter {
27
27
  this.runHistory = [];
28
28
  this.currentRunStartOffset = 0;
29
29
  this.skippedCount = 0;
30
+ this._silentChainCount = 0;
30
31
  this.id = id;
31
32
  this.options = options;
32
33
  this.logPath = logPath;
@@ -46,10 +47,17 @@ export class LoopController extends EventEmitter {
46
47
  this._paused = state?.status === "paused" || state?.status === "idle";
47
48
  this.runHistory = (state?.runHistory ?? []).map((r) => r.status === "running" ? { ...r, status: "completed" } : r).map((r) => ({ ...r, logOffset: r.logOffset ?? 0 }));
48
49
  this.skippedCount = state?.skippedCount ?? 0;
50
+ this._silentChainCount = state?.silentChainCount ?? 0;
49
51
  }
50
52
  get status() {
51
53
  return this._status;
52
54
  }
55
+ get silentChainCount() {
56
+ return this._silentChainCount;
57
+ }
58
+ incrementSilentChainCount() {
59
+ this._silentChainCount += 1;
60
+ }
53
61
  start() {
54
62
  if (this._loopActive)
55
63
  return;
@@ -185,6 +193,7 @@ export class LoopController extends EventEmitter {
185
193
  remainingDelayMs: this.remainingDelayMs,
186
194
  runHistory: this.runHistory,
187
195
  skippedCount: this.skippedCount,
196
+ silentChainCount: this._silentChainCount,
188
197
  };
189
198
  }
190
199
  clearMaxRunsReached() {
@@ -5,11 +5,22 @@ export function buildOpenApiSpec() {
5
5
  info: {
6
6
  title: "loop-task HTTP API",
7
7
  version: "1.0.0",
8
- description: "REST + SSE API for managing loops, tasks, projects, and logs. All endpoints are localhost-only (127.0.0.1).",
8
+ description: "REST + SSE API for managing loops, tasks, projects, and logs. Binds to all interfaces (0.0.0.0) by default so it is reachable remotely; the bind host is configurable via `loop-task http-host`. The API is unauthenticated, secure access at the network layer (SSH/Tailscale/firewall), or restrict the bind to 127.0.0.1.",
9
9
  },
10
10
  servers: [
11
11
  { url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
12
12
  ],
13
+ tags: [
14
+ { name: "Loops", description: "Loop management" },
15
+ { name: "Tasks", description: "Task management" },
16
+ { name: "Task Chains", description: "Create chained tasks" },
17
+ { name: "Projects", description: "Project management" },
18
+ { name: "Logs", description: "Log retrieval" },
19
+ { name: "Events", description: "Server-sent events" },
20
+ { name: "Settings", description: "Daemon settings" },
21
+ { name: "MCP", description: "Model Context Protocol server" },
22
+ { name: "Docs", description: "API documentation" },
23
+ ],
13
24
  paths: {
14
25
  "/api/loops": {
15
26
  get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
@@ -22,9 +33,12 @@ export function buildOpenApiSpec() {
22
33
  },
23
34
  "/api/loops/{id}/pause": { post: { summary: "Pause a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Paused" }, "404": { description: "Not found" } } } },
24
35
  "/api/loops/{id}/resume": { post: { summary: "Resume a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Resumed" }, "404": { description: "Not found" } } } },
36
+ "/api/loops/{id}/play": { post: { summary: "Play (start) a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Playing", content: { "application/json": { schema: { type: "object" } } } }, "404": { description: "Not found" }, "409": { description: "Already running or max runs blocked" } } } },
25
37
  "/api/loops/{id}/trigger": { post: { summary: "Trigger a loop now", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Triggered" }, "404": { description: "Not found" }, "400": { description: "Max runs reached" }, "409": { description: "Already running" } } } },
26
38
  "/api/loops/{id}/stop": { post: { summary: "Stop a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Stopped" }, "404": { description: "Not found" } } } },
27
39
  "/api/loops/stop-all": { post: { summary: "Stop all loops", tags: ["Loops"], responses: { "200": { description: "All stopped", content: { "application/json": { schema: { type: "object", properties: { count: { type: "integer" } } } } } } } } },
40
+ "/api/loops/{id}/runs": { get: { summary: "List run history", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", schema: { type: "string", description: "ISO 8601 date" } }, { name: "to", in: "query", schema: { type: "string", description: "ISO 8601 date" } }], responses: { "200": { description: "Run records", content: { "application/json": { schema: { type: "array" } } } }, "404": { description: "Not found" } } } },
41
+ "/api/loops/{id}/logs/date": { get: { summary: "Date-filtered logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", required: true, schema: { type: "string", description: "ISO 8601 start date" } }, { name: "to", in: "query", required: true, schema: { type: "string", description: "ISO 8601 end date" } }], responses: { "200": { description: "Filtered log content" }, "404": { description: "Not found" }, "400": { description: "Missing or invalid date params" } } } },
28
42
  "/api/loops/{id}/logs": { get: { summary: "Fetch loop logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer", default: 50 } }], responses: { "200": { description: "Log content" }, "404": { description: "Not found" } } } },
29
43
  "/api/loops/{id}/logs/stream": { get: { summary: "Stream logs via SSE", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer" } }], responses: { "200": { description: "SSE stream" }, "404": { description: "Not found" } } } },
30
44
  "/api/loops/{id}/runs/{num}": { get: { summary: "Get run-specific log", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "num", in: "path", required: true, schema: { type: "integer" } }], responses: { "200": { description: "Run log content" } } } },
@@ -37,18 +51,31 @@ export function buildOpenApiSpec() {
37
51
  patch: { summary: "Update a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object" } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" } } },
38
52
  delete: { summary: "Delete a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "404": { description: "Not found" } } },
39
53
  },
54
+ "/api/task-chains": { post: { summary: "Create a chain of tasks", tags: ["Task Chains"], requestBody: { content: { "application/json": { schema: { type: "object", required: ["tasks"], properties: { tasks: { type: "array", items: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } }, chain: { type: "string", enum: ["sequential-success", "sequential-failure", "none"], description: "How to wire tasks together" } } } } } }, responses: { "201": { description: "Chain created", content: { "application/json": { schema: { type: "object", properties: { taskIds: { type: "array", items: { type: "string" } } } } } } }, "400": { description: "Validation error or rollback" } } } },
40
55
  "/api/projects": {
41
56
  get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
42
- post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
57
+ post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
43
58
  },
44
59
  "/api/projects/{id}": {
45
- patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
60
+ patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
46
61
  delete: { summary: "Delete a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "400": { description: "Cannot delete system project" } } },
47
62
  },
63
+ "/api/settings": { get: { summary: "Get daemon settings", tags: ["Settings"], responses: { "200": { description: "Current settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } } } }, patch: { summary: "Update daemon settings", tags: ["Settings"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, responses: { "200": { description: "Updated settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, "400": { description: "Validation error" } } } },
48
64
  "/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
49
65
  "/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
50
66
  "/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
51
67
  },
68
+ components: {
69
+ schemas: {
70
+ DaemonSettings: {
71
+ type: "object",
72
+ properties: {
73
+ httpApiEnabled: { type: "boolean", description: "Whether the HTTP API server is enabled" },
74
+ mcpApiEnabled: { type: "boolean", description: "Whether the MCP server is enabled" },
75
+ },
76
+ },
77
+ },
78
+ },
52
79
  };
53
80
  }
54
81
  export function buildSwaggerHtml() {
@@ -108,6 +108,22 @@ export function registerLoopRoutes(manager, routes, r) {
108
108
  }
109
109
  sendOk(res);
110
110
  });
111
+ r("POST", "/api/loops/:id/play", (_req, res, params) => {
112
+ if (manager.isMaxRunsBlocked(params.id)) {
113
+ sendError(res, 409, "Max runs reached");
114
+ return;
115
+ }
116
+ if (manager.isRunning(params.id)) {
117
+ sendError(res, 409, "Loop is already running");
118
+ return;
119
+ }
120
+ if (!manager.playLoop(params.id)) {
121
+ sendNotFound(res, params.id);
122
+ return;
123
+ }
124
+ const meta = manager.status(params.id);
125
+ sendOk(res, meta);
126
+ });
111
127
  r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
112
128
  if (manager.isMaxRunsBlocked(params.id)) {
113
129
  sendError(res, 400, "Max runs reached");
@@ -134,6 +150,75 @@ export function registerLoopRoutes(manager, routes, r) {
134
150
  const count = manager.stopAllLoops();
135
151
  sendOk(res, { count });
136
152
  });
153
+ // --- Run History ---
154
+ r("GET", "/api/loops/:id/runs", (_req, res, params) => {
155
+ const meta = manager.status(params.id);
156
+ if (!meta) {
157
+ sendNotFound(res, params.id);
158
+ return;
159
+ }
160
+ const query = parseQuery(_req.url);
161
+ const fromStr = query.get("from");
162
+ const toStr = query.get("to");
163
+ let runs = meta.runHistory;
164
+ if (fromStr) {
165
+ const fromMs = new Date(fromStr).getTime();
166
+ if (!Number.isNaN(fromMs)) {
167
+ runs = runs.filter((r) => new Date(r.startedAt).getTime() >= fromMs);
168
+ }
169
+ }
170
+ if (toStr) {
171
+ const toMs = new Date(toStr).getTime();
172
+ if (!Number.isNaN(toMs)) {
173
+ runs = runs.filter((r) => new Date(r.startedAt).getTime() <= toMs);
174
+ }
175
+ }
176
+ sendOk(res, runs);
177
+ });
178
+ // --- Date-Filtered Logs ---
179
+ r("GET", "/api/loops/:id/logs/date", (_req, res, params) => {
180
+ const meta = manager.status(params.id);
181
+ if (!meta) {
182
+ sendNotFound(res, params.id);
183
+ return;
184
+ }
185
+ const query = parseQuery(_req.url);
186
+ const fromStr = query.get("from");
187
+ const toStr = query.get("to");
188
+ if (!fromStr || !toStr) {
189
+ sendError(res, 400, "Both 'from' and 'to' query parameters are required (ISO 8601)");
190
+ return;
191
+ }
192
+ const fromMs = new Date(fromStr).getTime();
193
+ const toMs = new Date(toStr).getTime();
194
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
195
+ sendError(res, 400, "Invalid date format for 'from' or 'to' (use ISO 8601)");
196
+ return;
197
+ }
198
+ const logPath = manager.getLogPath(params.id);
199
+ if (!logPath || !fs.existsSync(logPath)) {
200
+ sendOk(res, "");
201
+ return;
202
+ }
203
+ const matching = meta.runHistory.filter((r) => {
204
+ const t = new Date(r.startedAt).getTime();
205
+ return t >= fromMs && t <= toMs;
206
+ });
207
+ if (matching.length === 0) {
208
+ sendOk(res, "");
209
+ return;
210
+ }
211
+ matching.sort((a, b) => a.logOffset - b.logOffset);
212
+ const buffer = fs.readFileSync(logPath);
213
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
214
+ const parts = matching.map((record) => {
215
+ const start = record.logOffset;
216
+ const idx = allSorted.indexOf(record);
217
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
218
+ return buffer.toString("utf-8", start, end);
219
+ });
220
+ sendOk(res, parts.join(""));
221
+ });
137
222
  // --- Logs ---
138
223
  r("GET", "/api/loops/:id/logs", (_req, res, params) => {
139
224
  const logPath = manager.getLogPath(params.id);
@@ -1,5 +1,6 @@
1
1
  import { initSseResponse } from "./sse.js";
2
2
  import { buildOpenApiSpec, buildSwaggerHtml } from "./openapi.js";
3
+ import { sendOk, sendError, readBody } from "./helpers.js";
3
4
  export function registerMiscRoutes(sseClients, r) {
4
5
  r("GET", "/api/openapi.json", (_req, res) => {
5
6
  const spec = buildOpenApiSpec();
@@ -34,3 +35,18 @@ export function registerMiscRoutes(sseClients, r) {
34
35
  });
35
36
  });
36
37
  }
38
+ export function registerSettingsRoutes(settingsManager, r) {
39
+ r("GET", "/api/settings", (_req, res) => {
40
+ sendOk(res, settingsManager.get());
41
+ });
42
+ r("PATCH", "/api/settings", async (req, res) => {
43
+ try {
44
+ const body = await readBody(req);
45
+ const updated = settingsManager.set(body);
46
+ sendOk(res, updated);
47
+ }
48
+ catch (err) {
49
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
50
+ }
51
+ });
52
+ }
@@ -10,7 +10,7 @@ export function registerProjectRoutes(projectManager, r) {
10
10
  sendError(res, 400, "Project name is required");
11
11
  return;
12
12
  }
13
- const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff");
13
+ const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff", body.directory, body.githubSource);
14
14
  sendOk(res, project, 201);
15
15
  }
16
16
  catch (err) {
@@ -24,7 +24,7 @@ export function registerProjectRoutes(projectManager, r) {
24
24
  sendError(res, 400, "Project name is required");
25
25
  return;
26
26
  }
27
- projectManager.update(params.id, body.name.trim(), body.color);
27
+ projectManager.update(params.id, body.name.trim(), body.color, body.directory, body.githubSource);
28
28
  sendOk(res);
29
29
  }
30
30
  catch (err) {