loop-task 2.1.13 → 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/cli.js +45 -0
- package/dist/daemon/http/openapi.js +1 -1
- package/dist/daemon/index.js +23 -2
- package/dist/daemon/mcp/server.js +7 -2
- package/dist/daemon/settings-manager.js +9 -1
- package/dist/features/commands/commands.js +0 -1
- package/dist/features/commands/useCommandHandlers.js +0 -1
- package/dist/features/commands/useGlobalShortcuts.js +0 -1
- package/dist/shared/i18n/en.json +2 -3
- package/package.json +1 -1
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/cli.js
CHANGED
|
@@ -336,6 +336,51 @@ program
|
|
|
336
336
|
console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
|
|
337
337
|
console.log(` Events: ${baseUrl}/api/events (SSE)`);
|
|
338
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
|
+
});
|
|
339
384
|
program
|
|
340
385
|
.command("mcp")
|
|
341
386
|
.description("Show MCP server info and how to connect an MCP client")
|
|
@@ -5,7 +5,7 @@ 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" },
|
package/dist/daemon/index.js
CHANGED
|
@@ -32,14 +32,16 @@ async function main() {
|
|
|
32
32
|
}
|
|
33
33
|
const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
|
|
34
34
|
const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
|
|
35
|
+
let currentHttpHost = settingsManager.get().httpApiHost;
|
|
35
36
|
if (settingsManager.get().httpApiEnabled) {
|
|
36
37
|
try {
|
|
37
|
-
await httpServer.listen(resolvedHttpPort);
|
|
38
|
+
await httpServer.listen(resolvedHttpPort, currentHttpHost);
|
|
38
39
|
}
|
|
39
40
|
catch (err) {
|
|
40
41
|
daemonLog(`HTTP API server failed to start: ${String(err)}`);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
mcpServer.setHost(currentHttpHost);
|
|
43
45
|
if (mcpEnabled) {
|
|
44
46
|
try {
|
|
45
47
|
await mcpServer.start();
|
|
@@ -49,26 +51,45 @@ async function main() {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
settingsManager.onChange((settings) => {
|
|
54
|
+
const newHost = settings.httpApiHost;
|
|
55
|
+
const hostChanged = newHost !== currentHttpHost;
|
|
56
|
+
// HTTP API (8845)
|
|
52
57
|
if (settings.httpApiEnabled && !httpServer["isListening"]) {
|
|
53
|
-
httpServer.listen(resolvedHttpPort).catch((err) => {
|
|
58
|
+
httpServer.listen(resolvedHttpPort, newHost).catch((err) => {
|
|
54
59
|
daemonLog(`HTTP API server failed to restart: ${String(err)}`);
|
|
55
60
|
});
|
|
56
61
|
}
|
|
62
|
+
else if (settings.httpApiEnabled && httpServer["isListening"] && hostChanged) {
|
|
63
|
+
httpServer.restart(resolvedHttpPort, newHost).catch((err) => {
|
|
64
|
+
daemonLog(`HTTP API server failed to rebind to ${newHost}: ${String(err)}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
57
67
|
else if (!settings.httpApiEnabled && httpServer["isListening"]) {
|
|
58
68
|
httpServer.close().catch((err) => {
|
|
59
69
|
daemonLog(`HTTP API server failed to stop: ${String(err)}`);
|
|
60
70
|
});
|
|
61
71
|
}
|
|
72
|
+
// MCP SSE (8846) — shares the same bind host as the HTTP API.
|
|
73
|
+
mcpServer.setHost(newHost);
|
|
62
74
|
if (settings.mcpApiEnabled && !mcpServer.isListening) {
|
|
63
75
|
mcpServer.start().catch((err) => {
|
|
64
76
|
daemonLog(`MCP server failed to restart: ${String(err)}`);
|
|
65
77
|
});
|
|
66
78
|
}
|
|
79
|
+
else if (settings.mcpApiEnabled && mcpServer.isListening && hostChanged) {
|
|
80
|
+
mcpServer
|
|
81
|
+
.close()
|
|
82
|
+
.then(() => mcpServer.start())
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
daemonLog(`MCP server failed to rebind to ${newHost}: ${String(err)}`);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
67
87
|
else if (!settings.mcpApiEnabled && mcpServer.isListening) {
|
|
68
88
|
mcpServer.close().catch((err) => {
|
|
69
89
|
daemonLog(`MCP server failed to stop: ${String(err)}`);
|
|
70
90
|
});
|
|
71
91
|
}
|
|
92
|
+
currentHttpHost = newHost;
|
|
72
93
|
});
|
|
73
94
|
manager.init();
|
|
74
95
|
writeDaemonPid(process.pid);
|
|
@@ -14,8 +14,13 @@ export class McpApiServer {
|
|
|
14
14
|
this.activeTransport = null;
|
|
15
15
|
this.httpServer = null;
|
|
16
16
|
this._isListening = false;
|
|
17
|
+
this.host = "127.0.0.1";
|
|
17
18
|
this.transportType = transport ?? "sse";
|
|
18
19
|
}
|
|
20
|
+
/** Set the bind host for the SSE transport (takes effect on next start). */
|
|
21
|
+
setHost(host) {
|
|
22
|
+
this.host = host;
|
|
23
|
+
}
|
|
19
24
|
createServer() {
|
|
20
25
|
const server = new McpServer({ name: "loop-task", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
21
26
|
registerMcpTools(server, { manager: this.manager, taskManager: this.taskManager, projectManager: this.projectManager });
|
|
@@ -136,8 +141,8 @@ export class McpApiServer {
|
|
|
136
141
|
}
|
|
137
142
|
resolve();
|
|
138
143
|
});
|
|
139
|
-
this.httpServer.listen(port,
|
|
140
|
-
daemonLog(`MCP SSE server listening on
|
|
144
|
+
this.httpServer.listen(port, this.host, () => {
|
|
145
|
+
daemonLog(`MCP SSE server listening on ${this.host}:${port}`);
|
|
141
146
|
this._isListening = true;
|
|
142
147
|
resolve();
|
|
143
148
|
});
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { settingsJson } from "../shared/config/paths.js";
|
|
3
3
|
import { daemonLog } from "./daemon-log.js";
|
|
4
|
-
const DEFAULTS = {
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
httpApiEnabled: true,
|
|
6
|
+
mcpApiEnabled: true,
|
|
7
|
+
// Binds to all interfaces by default: the daemon is meant to be reachable
|
|
8
|
+
// (e.g. over SSH/Tailscale/LAN). The API is unauthenticated, so securing
|
|
9
|
+
// access is the operator's job at the network layer; restrict the bind with
|
|
10
|
+
// `loop-task http-host <ip|local>` when you want loopback-only.
|
|
11
|
+
httpApiHost: "0.0.0.0",
|
|
12
|
+
};
|
|
5
13
|
export class SettingsManager {
|
|
6
14
|
constructor() {
|
|
7
15
|
this.settings = { ...DEFAULTS };
|
|
@@ -10,7 +10,6 @@ function globalCommands() {
|
|
|
10
10
|
{ label: t('cmd.toggleMcp'), value: 'toggle-mcp', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
|
|
11
11
|
{ label: t('cmd.export'), value: 'export', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+x' },
|
|
12
12
|
{ label: t('cmd.import'), value: 'import', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+i' },
|
|
13
|
-
{ label: t('cmd.status'), value: 'status', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+y' },
|
|
14
13
|
];
|
|
15
14
|
}
|
|
16
15
|
function loopFilterCommands() {
|
|
@@ -160,7 +160,6 @@ export function useCommandHandlers(context) {
|
|
|
160
160
|
pushToast("error", t("board.toastMcpToggleError", { message: e.message }));
|
|
161
161
|
}
|
|
162
162
|
},
|
|
163
|
-
status: () => { pushToast("info", `Command "status" coming soon`); },
|
|
164
163
|
export: () => {
|
|
165
164
|
exportService.exportConfig()
|
|
166
165
|
.then(({ json, filePath }) => setExportModal({ json, filePath, error: null }))
|
|
@@ -48,7 +48,6 @@ export function useGlobalShortcuts(context) {
|
|
|
48
48
|
b: () => handleCommand("debug"),
|
|
49
49
|
x: () => handleCommand("export"),
|
|
50
50
|
i: () => handleCommand("import"),
|
|
51
|
-
y: () => handleCommand("status"),
|
|
52
51
|
};
|
|
53
52
|
const filterShortcuts = {
|
|
54
53
|
s: () => handleCommand("search"),
|
package/dist/shared/i18n/en.json
CHANGED
|
@@ -297,7 +297,7 @@
|
|
|
297
297
|
"codeEditor.fieldHint": "press enter to open editor",
|
|
298
298
|
"codeEditor.copied": "Copied!",
|
|
299
299
|
"codeEditor.cleared": "Cleared",
|
|
300
|
-
"codeEditor.clipboardEmpty": "Clipboard empty
|
|
300
|
+
"codeEditor.clipboardEmpty": "Clipboard empty, right-click to paste",
|
|
301
301
|
"codeEditor.commandsTitle": "Parallel Commands",
|
|
302
302
|
"codeEditor.commandsHint": "tab: next . shift+tab: prev . ctrl+s: save all . esc: cancel",
|
|
303
303
|
"codeEditor.commandLabel": "Command",
|
|
@@ -531,7 +531,6 @@
|
|
|
531
531
|
"board.toastMcpEnabled": "MCP server enabled",
|
|
532
532
|
"board.toastMcpDisabled": "MCP server disabled",
|
|
533
533
|
"board.toastMcpToggleError": "Failed to toggle MCP: {message}",
|
|
534
|
-
"cmd.status": "Daemon status info (Soon...)",
|
|
535
534
|
"cmd.filterStatus": "Filter by status",
|
|
536
535
|
"cmd.sort": "Order sort by field",
|
|
537
536
|
"cmd.project": "Filter by project",
|
|
@@ -550,7 +549,7 @@
|
|
|
550
549
|
"export.modalTitle": "Export Loops JSON",
|
|
551
550
|
"export.filePath": "Saved to: {path}",
|
|
552
551
|
"export.error": "Export failed: {message}",
|
|
553
|
-
"export.truncated": "[truncated
|
|
552
|
+
"export.truncated": "[truncated, {total} lines]",
|
|
554
553
|
"export.hint": "up/down:scroll c:copy esc:close",
|
|
555
554
|
"cmd.quit": "Exit loop-task",
|
|
556
555
|
"cmd.save": "Save changes",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.14",
|
|
4
4
|
"description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|