loop-task 1.4.5 → 1.4.7

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 CHANGED
@@ -50,6 +50,8 @@ loop-task # open the board (requires Bun)
50
50
  loop-task start # start the daemon, restore persisted loops
51
51
  loop-task new 30m -- npm test # create a background loop
52
52
  loop-task run --now 10s -- echo hi # run a loop in the foreground
53
+ loop-task stop <id> # stop a frozen loop and kill its child process
54
+ loop-task restart # kill daemon + all loops, restart fresh
53
55
  ```
54
56
 
55
57
  Or run it directly:
@@ -137,6 +139,8 @@ Colors can be a name (`white`, `cyan`, `green`, `yellow`, `orange`, `pink`) or a
137
139
  | `loop-task new <interval> -- <command>` | Create a background loop (creates an inline task) |
138
140
  | `loop-task new <interval> --project <name> -- <command>` | Create a loop assigned to a project |
139
141
  | `loop-task run <interval> -- <command>` | Run a loop in the foreground |
142
+ | `loop-task stop <id>` | Stop a loop and interrupt its running child process |
143
+ | `loop-task restart` | Kill the daemon and all running loops, then restart fresh |
140
144
  | `loop-task project list` | List all projects |
141
145
  | `loop-task project new <name> [--color <color>]` | Create a project |
142
146
  | `loop-task project rename <id\|name> <new-name>` | Rename a project |
package/dist/board/App.js CHANGED
@@ -26,7 +26,7 @@ import { ProjectsPage } from "./components/ProjectsPage.js";
26
26
  import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, triggerLoop, listTasks, deleteTask, listProjects } from "./daemon.js";
27
27
  import { useBreakpoint } from "./hooks/useBreakpoint.js";
28
28
  import { useRouter } from "./router.js";
29
- const BOARD_REFRESH_DELAY_MS = 150;
29
+ import { POLL_MS } from "../config/constants.js";
30
30
  const VIEW_TO_MODE = {
31
31
  create: "create",
32
32
  "task-create": "task",
@@ -117,14 +117,20 @@ export function App(props) {
117
117
  }
118
118
  catch { /* ignore */ }
119
119
  }
120
- useState(() => { void refreshTasks(); });
121
120
  async function refreshProjects() {
122
121
  try {
123
122
  setProjects(await listProjects());
124
123
  }
125
124
  catch { /* ignore */ }
126
125
  }
127
- useState(() => { void refreshProjects(); });
126
+ useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
127
+ useEffect(() => {
128
+ const timer = setInterval(() => {
129
+ void refreshTasks();
130
+ void refreshProjects();
131
+ }, POLL_MS);
132
+ return () => clearInterval(timer);
133
+ }, []);
128
134
  useEffect(() => {
129
135
  try {
130
136
  localStorage.setItem("loop-current-project", currentProjectId);
@@ -135,7 +141,7 @@ export function App(props) {
135
141
  return async () => {
136
142
  try {
137
143
  await action();
138
- setTimeout(() => { void refresh(); }, BOARD_REFRESH_DELAY_MS);
144
+ void refresh();
139
145
  pushToast("success", label);
140
146
  }
141
147
  catch (error) {
@@ -266,7 +272,7 @@ export function App(props) {
266
272
  setPendingTaskSelection(null);
267
273
  pop();
268
274
  pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
269
- setTimeout(() => { void refresh(); }, BOARD_REFRESH_DELAY_MS);
275
+ void refresh();
270
276
  };
271
277
  const onTaskDone = (updated, id) => {
272
278
  setEditTask(null);
@@ -309,7 +315,7 @@ export function App(props) {
309
315
  onOpenRunLog: handleOpenRunLog,
310
316
  refreshTasks,
311
317
  onViewTasks: () => { void refreshTasks(); push("task-list"); },
312
- onViewProjects: () => push("projects"),
318
+ onViewProjects: () => { void refreshProjects(); push("projects"); },
313
319
  onViewLoops: () => replace("board"),
314
320
  onAddLoop: () => { setEditTarget(null); push("create"); },
315
321
  onAddTask: () => { setEditTask(null); push("task-create"); },
@@ -26,9 +26,9 @@ export function Navigator(props) {
26
26
  const bulletW = 2;
27
27
  const nonVar = 2 + 1 + statusW + 1 + 1 + 1 + exitW + 1 + 1 + runsW + 1 + skpW;
28
28
  const avail = Math.floor(panelWidth) - nonVar - bulletW;
29
- const descW = Math.min(22, Math.max(6, Math.round(avail * 0.30)));
30
- const sinceW = Math.min(14, Math.max(8, Math.round(avail * 0.35)));
31
- const timingW = Math.max(6, avail - descW - sinceW);
29
+ const descW = Math.min(32, Math.max(10, Math.round(avail * 0.38)));
30
+ const timingW = Math.min(16, Math.max(6, Math.round(avail * 0.25)));
31
+ const sinceW = Math.max(8, avail - descW - timingW);
32
32
  const header = " " +
33
33
  fit("", bulletW) +
34
34
  fit(t("board.headerDescription"), descW) +
@@ -9,33 +9,52 @@ export function useInputShortcuts(getActiveInput) {
9
9
  return;
10
10
  const name = key.name;
11
11
  if (name === "c") {
12
- const text = input.getSelectedText();
13
- if (text) {
14
- copyToClipboard(text);
12
+ try {
13
+ const text = input.getSelectedText();
14
+ if (text) {
15
+ copyToClipboard(text);
16
+ }
17
+ }
18
+ catch (e) {
19
+ console.error(`[loop-task] copy failed: ${e}`);
15
20
  }
16
21
  key.preventDefault();
22
+ key.stopPropagation();
17
23
  return;
18
24
  }
19
25
  if (name === "x") {
20
- const text = input.getSelectedText();
21
- if (text) {
22
- copyToClipboard(text);
23
- input.deleteSelection();
26
+ try {
27
+ const text = input.getSelectedText();
28
+ if (text) {
29
+ copyToClipboard(text);
30
+ input.deleteSelection();
31
+ }
32
+ }
33
+ catch (e) {
34
+ console.error(`[loop-task] cut failed: ${e}`);
24
35
  }
25
36
  key.preventDefault();
37
+ key.stopPropagation();
26
38
  return;
27
39
  }
28
40
  if (name === "v") {
29
- const text = readFromClipboard();
30
- if (text) {
31
- input.insertText(text);
41
+ try {
42
+ const text = readFromClipboard();
43
+ if (text) {
44
+ input.insertText(text);
45
+ }
46
+ }
47
+ catch (e) {
48
+ console.error(`[loop-task] paste failed: ${e}`);
32
49
  }
33
50
  key.preventDefault();
51
+ key.stopPropagation();
34
52
  return;
35
53
  }
36
54
  if (name === "a") {
37
55
  input.selectAll();
38
56
  key.preventDefault();
57
+ key.stopPropagation();
39
58
  return;
40
59
  }
41
60
  });
@@ -46,7 +46,7 @@ export function ToastStack(props) {
46
46
  }
47
47
  return (_jsx("box", { style: {
48
48
  position: "absolute",
49
- bottom: 1,
49
+ bottom: 3,
50
50
  right: 2,
51
51
  flexDirection: "column",
52
52
  alignItems: "flex-end",
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
4
4
  import { Logger } from "./logger.js";
5
5
  import { runLoop } from "./core/foreground-loop.js";
6
6
  import { buildLoopOptions } from "./loop-config.js";
7
+ import { parseDuration } from "./duration.js";
7
8
  import { startLoop } from "./client/commands.js";
8
9
  import { listProjectsCli, createProjectCli, renameProjectCli, setProjectColorCli, deleteProjectCli, resolveProjectId, } from "./client/commands.js";
9
10
  import { t } from "./i18n/index.js";
@@ -22,6 +23,49 @@ program
22
23
  ensureDaemon();
23
24
  console.log(t("cli.daemonStarted"));
24
25
  });
26
+ program
27
+ .command("stop")
28
+ .description("Stop a running loop and interrupt its current execution")
29
+ .argument("<id>", "Loop ID")
30
+ .action(async (id) => {
31
+ try {
32
+ const { sendRequest } = await import("./client/ipc.js");
33
+ const res = await sendRequest({ type: "stop-loop", payload: { id } });
34
+ if (res.type === "ok" && res.data) {
35
+ console.log(`Stopped loop ${id}`);
36
+ }
37
+ else if (res.type === "error") {
38
+ console.error(res.message);
39
+ process.exit(1);
40
+ }
41
+ else {
42
+ console.error(`Loop ${id} not found`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ console.error(t("cli.error", { message }));
49
+ process.exit(1);
50
+ }
51
+ });
52
+ program
53
+ .command("restart")
54
+ .description("Kill the daemon and all running loops, then restart fresh")
55
+ .action(async () => {
56
+ const { stopDaemon, ensureDaemon } = await import("./daemon/spawner.js");
57
+ const { readDaemonPid, removeDaemonPid, removeDaemonSignature } = await import("./daemon/state.js");
58
+ const pid = readDaemonPid();
59
+ if (pid !== null) {
60
+ console.log("Stopping daemon...");
61
+ stopDaemon(pid);
62
+ removeDaemonPid();
63
+ removeDaemonSignature();
64
+ }
65
+ console.log("Starting fresh daemon...");
66
+ ensureDaemon();
67
+ console.log("Daemon restarted.");
68
+ });
25
69
  program
26
70
  .command("new")
27
71
  .description(t("cli.newDescription"))
@@ -32,17 +76,20 @@ program
32
76
  .option("--verbose", t("cli.optVerbose"), false)
33
77
  .option("--cwd <dir>", t("cli.optCwd"))
34
78
  .option("--project <name>", t("cli.optProject"))
79
+ .option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
35
80
  .action(async (intervalStr, cmdArgs, opts) => {
36
81
  try {
37
82
  const projectId = opts.project
38
83
  ? await resolveProjectId(opts.project)
39
84
  : undefined;
85
+ const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
40
86
  const built = buildLoopOptions(intervalStr, {
41
87
  ...opts,
42
88
  command: cmdArgs[0],
43
89
  commandArgs: cmdArgs.slice(1),
44
90
  cwd: opts.cwd ?? process.cwd(),
45
91
  projectId,
92
+ offset: offsetMs,
46
93
  });
47
94
  await startLoop(built.options, built.intervalHuman);
48
95
  }
@@ -5,6 +5,7 @@ import { sleep } from "../shared/sleep.js";
5
5
  import { SLEEP_CHUNK_MS } from "../config/constants.js";
6
6
  import { executeCommand } from "./command-runner.js";
7
7
  import { rotateLogIfNeeded } from "./log-rotator.js";
8
+ import { computePhase, alignToPhase } from "./scheduling.js";
8
9
  import { t } from "../i18n/index.js";
9
10
  export class LoopController extends EventEmitter {
10
11
  abortController;
@@ -116,7 +117,11 @@ export class LoopController extends EventEmitter {
116
117
  this._resetSchedule = true;
117
118
  this._paused = false;
118
119
  this._status = "waiting";
119
- this.nextRunAt = new Date(Date.now() + this.options.interval).toISOString();
120
+ const phase = this.options.offset !== null
121
+ ? this.options.offset
122
+ : computePhase(this.id, this.options.interval);
123
+ const delay = alignToPhase(Date.now(), this.options.interval, phase);
124
+ this.nextRunAt = new Date(Date.now() + delay).toISOString();
120
125
  if (this.resumeResolve) {
121
126
  this.resumeResolve();
122
127
  this.resumeResolve = null;
@@ -274,7 +279,14 @@ export class LoopController extends EventEmitter {
274
279
  }
275
280
  }
276
281
  else if (isFirstRun && !this.options.immediate) {
277
- const completed = await this.waitForDelay(this.options.interval, signal);
282
+ const phase = this.options.offset !== null
283
+ ? this.options.offset
284
+ : computePhase(this.id, this.options.interval);
285
+ const delay = alignToPhase(Date.now(), this.options.interval, phase);
286
+ if (delay > 0) {
287
+ this.nextRunAt = new Date(Date.now() + delay).toISOString();
288
+ }
289
+ const completed = await this.waitForDelay(delay, signal);
278
290
  if (!completed) {
279
291
  break;
280
292
  }
@@ -0,0 +1,12 @@
1
+ export function computePhase(loopId, intervalMs) {
2
+ let hash = 0;
3
+ for (let i = 0; i < loopId.length; i++) {
4
+ hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
5
+ }
6
+ return Math.abs(hash) % intervalMs;
7
+ }
8
+ export function alignToPhase(now, intervalMs, phaseMs) {
9
+ const elapsed = now % intervalMs;
10
+ const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
11
+ return delay;
12
+ }
@@ -44,6 +44,7 @@ export class LoopManager {
44
44
  verbose: meta.verbose,
45
45
  description: meta.description ?? "",
46
46
  projectId: meta.projectId ?? "default",
47
+ offset: meta.offset ?? null,
47
48
  };
48
49
  const logPath = getLogPath(meta.id);
49
50
  const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
@@ -161,10 +162,19 @@ export class LoopManager {
161
162
  const entry = this.loops.get(id);
162
163
  if (!entry)
163
164
  return false;
164
- entry.controller.stopLoop();
165
+ entry.controller.stopLoop(true);
165
166
  this.persist(id, entry.controller, entry.options, entry.intervalHuman);
166
167
  return true;
167
168
  }
169
+ stopAllLoops() {
170
+ let count = 0;
171
+ for (const [id, entry] of this.loops) {
172
+ entry.controller.stopLoop(true);
173
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
174
+ count++;
175
+ }
176
+ return count;
177
+ }
168
178
  playLoop(id) {
169
179
  const entry = this.loops.get(id);
170
180
  if (!entry)
@@ -255,6 +265,7 @@ export class LoopManager {
255
265
  remainingDelayMs: runtime.remainingDelayMs,
256
266
  pid: process.pid,
257
267
  projectId: options.projectId ?? "default",
268
+ offset: options.offset,
258
269
  };
259
270
  }
260
271
  }
@@ -112,6 +112,11 @@ export class IpcServer {
112
112
  this.respondOk(socket, this.manager.stopLoop(request.payload.id), request.payload.id);
113
113
  break;
114
114
  }
115
+ case "stop-all": {
116
+ const count = this.manager.stopAllLoops();
117
+ send(socket, { type: "ok", data: count });
118
+ break;
119
+ }
115
120
  case "play-loop": {
116
121
  if (this.manager.isMaxRunsBlocked(request.payload.id)) {
117
122
  send(socket, { type: "error", message: t("errors.maxRunsReached") });
@@ -16,7 +16,7 @@ function currentCodeSignature() {
16
16
  function blockingWait(ms) {
17
17
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
18
18
  }
19
- function stopDaemon(pid) {
19
+ export function stopDaemon(pid) {
20
20
  try {
21
21
  process.kill(pid, "SIGTERM");
22
22
  }
package/dist/entry.js CHANGED
File without changes
@@ -78,6 +78,7 @@ export function buildLoopOptions(intervalHuman, input = {}) {
78
78
  verbose: input.verbose ?? false,
79
79
  description,
80
80
  projectId: input.projectId ?? "default",
81
+ offset: input.offset ?? null,
81
82
  },
82
83
  };
83
84
  }
package/package.json CHANGED
@@ -1,75 +1,74 @@
1
- {
2
- "name": "loop-task",
3
- "version": "1.4.5",
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
- "type": "module",
6
- "bin": {
7
- "loop-task": "dist/entry.js"
8
- },
9
- "main": "dist/entry.js",
10
- "files": [
11
- "dist"
12
- ],
13
- "scripts": {
14
- "build": "tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
15
- "prepublishOnly": "npm run build",
16
- "start": "node dist/entry.js",
17
- "dev": "bun src/cli.ts",
18
- "dev:watch": "bun --watch src/cli.ts",
19
- "board": "node dist/entry.js",
20
- "test": "vitest run --exclude '**/background-cli.test.ts'",
21
- "test:all": "vitest run",
22
- "test:watch": "vitest",
23
- "test:coverage": "vitest run --coverage --exclude '**/background-cli.test.ts'",
24
- "lint": "eslint src/ tests/",
25
- "typecheck": "tsc --noEmit",
26
- "release:dry": "npm publish --dry-run",
27
- "release": "npm publish"
28
- },
29
- "keywords": [
30
- "cli",
31
- "loop",
32
- "loop-engineering",
33
- "repeat",
34
- "interval",
35
- "schedule",
36
- "timer",
37
- "cron",
38
- "automation",
39
- "automations",
40
- "devops",
41
- "agent",
42
- "ai-agent",
43
- "coding-agent",
44
- "agent-automation",
45
- "claude-code",
46
- "codex",
47
- "opencode",
48
- "background-tasks",
49
- "scheduler"
50
- ],
51
- "author": "Quique Fdez Guerra",
52
- "license": "MIT",
53
- "engines": {
54
- "node": ">=20.0.0"
55
- },
56
- "dependencies": {
57
- "@opentui/core": "^0.4.1",
58
- "@opentui/react": "^0.4.1",
59
- "commander": "^13.1.0",
60
- "execa": "^9.6.0",
61
- "ms": "^2.1.3",
62
- "react": "^19.2.7"
63
- },
64
- "devDependencies": {
65
- "@types/ms": "^2.1.0",
66
- "@types/node": "^22.15.0",
67
- "@types/react": "^19.2.17",
68
- "@vitest/coverage-v8": "^3.1.0",
69
- "eslint": "^9.25.0",
70
- "tsx": "^4.19.0",
71
- "typescript": "^5.8.0",
72
- "typescript-eslint": "^8.30.0",
73
- "vitest": "^3.1.0"
74
- }
1
+ {
2
+ "name": "loop-task",
3
+ "version": "1.4.7",
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
+ "type": "module",
6
+ "bin": {
7
+ "loop-task": "dist/entry.js"
8
+ },
9
+ "main": "dist/entry.js",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "keywords": [
14
+ "cli",
15
+ "loop",
16
+ "loop-engineering",
17
+ "repeat",
18
+ "interval",
19
+ "schedule",
20
+ "timer",
21
+ "cron",
22
+ "automation",
23
+ "automations",
24
+ "devops",
25
+ "agent",
26
+ "ai-agent",
27
+ "coding-agent",
28
+ "agent-automation",
29
+ "claude-code",
30
+ "codex",
31
+ "opencode",
32
+ "background-tasks",
33
+ "scheduler"
34
+ ],
35
+ "author": "Quique Fdez Guerra",
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=20.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@opentui/core": "^0.4.1",
42
+ "@opentui/react": "^0.4.1",
43
+ "commander": "^13.1.0",
44
+ "execa": "^9.6.0",
45
+ "ms": "^2.1.3",
46
+ "react": "^19.2.7"
47
+ },
48
+ "devDependencies": {
49
+ "@types/ms": "^2.1.0",
50
+ "@types/node": "^22.15.0",
51
+ "@types/react": "^19.2.17",
52
+ "@vitest/coverage-v8": "^3.1.0",
53
+ "eslint": "^9.25.0",
54
+ "tsx": "^4.19.0",
55
+ "typescript": "^5.8.0",
56
+ "typescript-eslint": "^8.30.0",
57
+ "vitest": "^3.1.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
61
+ "start": "node dist/entry.js",
62
+ "dev": "bun src/cli.ts",
63
+ "dev:watch": "bun --watch src/cli.ts",
64
+ "board": "node dist/entry.js",
65
+ "test": "vitest run --exclude '**/background-cli.test.ts'",
66
+ "test:all": "vitest run",
67
+ "test:watch": "vitest",
68
+ "test:coverage": "vitest run --coverage --exclude '**/background-cli.test.ts'",
69
+ "lint": "eslint src/ tests/",
70
+ "typecheck": "tsc --noEmit",
71
+ "release:dry": "npm publish --dry-run",
72
+ "release": "npm publish"
73
+ }
75
74
  }