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.
- package/README.md +27 -9
- package/dist/app/App.js +3 -1
- package/dist/cli.js +89 -2
- package/dist/client/commands.js +4 -1
- package/dist/client/project-commands.js +7 -2
- package/dist/core/context/template.js +1 -1
- package/dist/core/context/validate-context.js +2 -2
- package/dist/core/loop/chain-executor.js +11 -4
- package/dist/core/loop/loop-controller.js +9 -0
- package/dist/daemon/http/openapi.js +30 -3
- package/dist/daemon/http/route-loops.js +85 -0
- package/dist/daemon/http/route-misc.js +16 -0
- package/dist/daemon/http/route-projects.js +2 -2
- package/dist/daemon/http/route-tasks.js +58 -0
- package/dist/daemon/http/routes.js +3 -2
- package/dist/daemon/http/server.js +20 -2
- package/dist/daemon/index.js +69 -7
- package/dist/daemon/managers/loop-entry.js +2 -0
- package/dist/daemon/managers/loop-manager.js +4 -4
- package/dist/daemon/managers/project-manager.js +16 -2
- package/dist/daemon/mcp/index.js +2 -0
- package/dist/daemon/mcp/openapi-sync.js +50 -0
- package/dist/daemon/mcp/server.js +167 -0
- package/dist/daemon/mcp/tools.js +368 -0
- package/dist/daemon/server/handlers/index.js +8 -1
- package/dist/daemon/server/handlers/project-handlers.js +10 -5
- package/dist/daemon/server/handlers/settings-handlers.js +8 -0
- package/dist/daemon/server/index.js +3 -1
- package/dist/daemon/settings-manager.js +54 -0
- package/dist/entities/tasks/filters.js +1 -1
- package/dist/features/commands/commands.js +4 -2
- package/dist/features/commands/useCommandHandlers.js +32 -1
- package/dist/features/commands/useGlobalShortcuts.js +0 -2
- package/dist/features/overlays/ExportModal.js +1 -1
- package/dist/loop-config.js +1 -1
- package/dist/shared/clipboard.js +2 -2
- package/dist/shared/config/paths.js +3 -0
- package/dist/shared/container/index.js +2 -0
- package/dist/shared/hooks/useDaemonSettings.js +39 -0
- package/dist/shared/hooks/useUndoRedo.js +1 -1
- package/dist/shared/i18n/en.json +25 -3
- package/dist/shared/services/project-service.js +4 -4
- package/dist/shared/services/settings-service.js +49 -0
- package/dist/shared/services/types.js +1 -0
- package/dist/shared/ui/FocusableInput.js +1 -1
- package/dist/shared/ui/SelectModal.js +1 -1
- package/dist/shared/utils/syntax.js +3 -3
- package/dist/widgets/header/Header.js +15 -2
- package/dist/widgets/left-panel/Navigator.js +3 -2
- package/dist/widgets/left-panel/TaskBrowser.js +3 -2
- package/dist/widgets/log-modal/LogModal.js +1 -1
- package/dist/widgets/loop-form/WizardForm.js +2 -2
- package/dist/widgets/loop-form/useHandleComplete.js +4 -1
- package/dist/widgets/project-form/ProjectForm.js +11 -2
- package/dist/widgets/right-panel/Inspector.js +1 -1
- package/dist/widgets/right-panel/RightPanel.js +1 -1
- package/dist/widgets/task-form/TaskForm.js +24 -4
- 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
|
|
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
|
|
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
|
|
443
|
-
- Board: press **Ctrl+G** or type `api
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/client/commands.js
CHANGED
|
@@ -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 loop
|
|
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 quotes
|
|
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 array
|
|
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 object
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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) {
|