loop-task 1.4.0 → 1.4.1

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
@@ -1,10 +1,11 @@
1
1
  <div align="center">
2
2
 
3
+
3
4
  # loop-task
4
5
 
5
- **Run commands on repeat. Manage them from a terminal board.**
6
+ **Loop engineering for your terminal. Run any command on a cadence.**
6
7
 
7
- `loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground.
8
+ `loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
8
9
 
9
10
  [![npm version](https://img.shields.io/npm/v/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
10
11
  [![npm downloads](https://img.shields.io/npm/dm/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
@@ -13,6 +14,34 @@
13
14
 
14
15
  </div>
15
16
 
17
+ ## Loop engineering
18
+
19
+ **Loop engineering** is designing systems that run work on a cadence instead of triggering each run yourself. A *loop* is a recurring goal: you define a purpose, give it an interval, and let it iterate. It applies to ordinary engineering work just as much as to AI agents: health checks, sync jobs, test watches, data pulls, deploy polls, and report generation are all loops.
20
+
21
+ `loop-task` is that heartbeat as a tiny local primitive. Some examples:
22
+
23
+ <div align="center">
24
+ <img src="https://raw.githubusercontent.com/CKGrafico/opencode-task/refs/heads/main/demo.gif" alt="opencode-task demo" width="700" />
25
+ </div>
26
+
27
+ ```bash
28
+ # Run the test suite every 30 minutes
29
+ loop-task new 30m -- npm test
30
+
31
+ # Poll a deploy every 10 seconds until you stop it
32
+ loop-task new 10s -- curl -sf https://example.com/health
33
+
34
+ # Re-sync a data export once an hour, scoped to a project
35
+ loop-task new 1h --project etl -- ./scripts/sync.sh
36
+
37
+ # Have a coding agent chip away at a backlog every 30 minutes
38
+ loop-task new 30m -- opencode run "find missing translations and translate them, 3 max"
39
+ ```
40
+
41
+ No cron files to maintain and no daemon to babysit: loops persist across reboots, run in the background, and you watch them from a terminal board. The idea is described well in Addy Osmani's [Loop Engineering](https://addyosmani.com/blog/loop-engineering/), where scheduled automations are the first of the five pieces of a working loop.
42
+
43
+ > **Stay in control.** A loop running unattended is also a loop failing unattended. Use `--max-runs`, watch the run history on the board, and review what each loop produces. The leverage moves to the loop; the responsibility stays with you.
44
+
16
45
  ## Quick start
17
46
 
18
47
  ```bash
@@ -32,8 +61,8 @@ npx loop-task new 30m -- npm test
32
61
 
33
62
  ## Requirements
34
63
 
35
- - **Node.js >= 20** required for all commands
36
- - **Bun >= 1.2** required for the interactive board only
64
+ - **Node.js >= 20** - required for all commands
65
+ - **Bun >= 1.2** - required for the interactive board only
37
66
 
38
67
  Install Bun:
39
68
 
@@ -47,7 +76,7 @@ npm install -g bun
47
76
 
48
77
  ### Loops
49
78
 
50
- A **loop** is a schedule it defines *when* something runs. Loops trigger **tasks**.
79
+ A **loop** is a schedule - it defines *when* something runs. Loops trigger **tasks**.
51
80
 
52
81
  | Field | Description |
53
82
  | ----- | ----------- |
@@ -59,7 +88,7 @@ A **loop** is a schedule — it defines *when* something runs. Loops trigger **t
59
88
 
60
89
  ### Tasks
61
90
 
62
- A **task** is an executable unit it defines *what* runs. Tasks can chain to other tasks on success or failure.
91
+ A **task** is an executable unit - it defines *what* runs. Tasks can chain to other tasks on success or failure.
63
92
 
64
93
  | Field | Description |
65
94
  | ----- | ----------- |
@@ -68,7 +97,7 @@ A **task** is an executable unit — it defines *what* runs. Tasks can chain to
68
97
  | **On success** | Optional task to run when this one exits with code 0 |
69
98
  | **On failure** | Optional task to run when this one exits with a non-zero code |
70
99
 
71
- Tasks are reusable the same task can be referenced by multiple loops or by other tasks' success/failure chains.
100
+ Tasks are reusable - the same task can be referenced by multiple loops or by other tasks' success/failure chains.
72
101
 
73
102
  ### Projects
74
103
 
@@ -80,9 +109,9 @@ A **project** is an organizational scope for loops. Every loop belongs to exactl
80
109
  | **Color** | One of six colors: white, cyan, orange, green, red, yellow |
81
110
 
82
111
  Key behaviors:
83
- - **Default project** always present, cannot be renamed or deleted. New loops are assigned here when no other project is selected.
84
- - **Color bullets** each loop in the navigator displays a colored bullet (●) matching its project color.
85
- - **Project filter** the board shows only loops belonging to the currently active project. The selection persists across sessions via `localStorage`.
112
+ - **Default project** - always present, cannot be renamed or deleted. New loops are assigned here when no other project is selected.
113
+ - **Color bullets** - each loop in the navigator displays a colored bullet (●) matching its project color.
114
+ - **Project filter** - the board shows only loops belonging to the currently active project. The selection persists across sessions via `localStorage`.
86
115
 
87
116
  To use projects from the board:
88
117
  - Press `c` to open the **Project selector** (switch between projects)
@@ -90,12 +119,12 @@ To use projects from the board:
90
119
  - From the Manage Projects page: `n` creates a new project, `e` renames the selected project, `d` deletes it, `Esc` returns to the board
91
120
 
92
121
  From the CLI:
93
- - `loop-task project list` list all projects
94
- - `loop-task project new <name> [--color <color>]` create a project
95
- - `loop-task project rename <id|name> <new-name>` rename a project
96
- - `loop-task project color <id|name> <color>` change a project's color
97
- - `loop-task project delete <id|name>` delete a project (loops move to Default)
98
- - `loop-task new <interval> --project <name> -- <command>` create a loop assigned to a project
122
+ - `loop-task project list` - list all projects
123
+ - `loop-task project new <name> [--color <color>]` - create a project
124
+ - `loop-task project rename <id|name> <new-name>` - rename a project
125
+ - `loop-task project color <id|name> <color>` - change a project's color
126
+ - `loop-task project delete <id|name>` - delete a project (loops move to Default)
127
+ - `loop-task new <interval> --project <name> -- <command>` - create a loop assigned to a project
99
128
 
100
129
  Colors can be a name (`white`, `cyan`, `green`, `yellow`, `orange`, `pink`) or a `#rrggbb` hex value.
101
130
 
@@ -137,7 +166,7 @@ loop-task new --now 1h -- npm test
137
166
  # Run up to 5 times, then stop
138
167
  loop-task run --max-runs 5 5m -- npm test
139
168
 
140
- # Agent workflow schedule an AI task every 30 minutes
169
+ # Agent workflow - schedule an AI task every 30 minutes
141
170
  loop-task new 30m --now -- opencode run "search missing translations and translate them, 3 maximum" --model "opencode/big-pickle"
142
171
  # Run in a specific directory
143
172
  loop-task new 30m --cwd ./packages/api -- npm test
@@ -178,8 +207,8 @@ Destructive actions (pause, force run, delete) prompt a confirmation before exec
178
207
 
179
208
  ### Pause vs Stop
180
209
 
181
- - **Pause** (`p`) temporarily halts the loop. Resuming continues the original schedule (e.g., a loop that runs every 6h at :00 paused at 12:00 and resumed at 14:00 will still fire at 16:00).
182
- - **Stop** (`s`) halts the loop and clears the schedule. Playing starts a fresh interval from now (e.g., the same loop stopped at 12:00 and played at 14:00 will fire at 20:00).
210
+ - **Pause** (`p`) - temporarily halts the loop. Resuming continues the original schedule (e.g., a loop that runs every 6h at :00 paused at 12:00 and resumed at 14:00 will still fire at 16:00).
211
+ - **Stop** (`s`) - halts the loop and clears the schedule. Playing starts a fresh interval from now (e.g., the same loop stopped at 12:00 and played at 14:00 will fire at 20:00).
183
212
 
184
213
  ## How it works
185
214
 
@@ -192,14 +221,14 @@ loop-task (board) ──IPC──► daemon ──► loop 1 ──► task (com
192
221
  - The **daemon** is a background process that manages all loops and tasks. It starts automatically when you run `loop-task start` or any command that needs it.
193
222
  - The **board** is a terminal UI that connects to the daemon via IPC.
194
223
  - **Loops** define schedules and reference tasks. **Tasks** define commands and optional success/failure chains.
195
- - Loops and tasks **persist to disk** they survive daemon restarts and system reboots. When the daemon starts, it restores all loops and accounts for elapsed time.
224
+ - Loops and tasks **persist to disk** - they survive daemon restarts and system reboots. When the daemon starts, it restores all loops and accounts for elapsed time.
196
225
 
197
226
  ### Lifecycle
198
227
 
199
228
  1. `loop-task start` or `loop-task new ...` spawns the daemon if not running
200
229
  2. The daemon creates a loop and a task, and persists their state to disk
201
230
  3. `loop-task` opens the board for interactive management
202
- 4. Closing the board or terminal does **not** stop loops the daemon keeps running
231
+ 4. Closing the board or terminal does **not** stop loops - the daemon keeps running
203
232
  5. After a reboot, `loop-task start` restores all persisted loops with correct timing
204
233
 
205
234
  ## Supported intervals
@@ -214,10 +243,10 @@ loop-task (board) ──IPC──► daemon ──► loop 1 ──► task (com
214
243
 
215
244
  ## Behavior
216
245
 
217
- - **No overlapping** waits for the command to finish before starting the next interval
218
- - **Resilient** continues looping even if a command exits with a non-zero code
219
- - **Persistent** loop and task state is saved after every run; survives restarts
220
- - **Graceful shutdown** background loops are daemon-managed; foreground loops finish the current execution on Ctrl+C
246
+ - **No overlapping** - waits for the command to finish before starting the next interval
247
+ - **Resilient** - continues looping even if a command exits with a non-zero code
248
+ - **Persistent** - loop and task state is saved after every run; survives restarts
249
+ - **Graceful shutdown** - background loops are daemon-managed; foreground loops finish the current execution on Ctrl+C
221
250
 
222
251
  ## Development
223
252
 
@@ -63,7 +63,7 @@ export function CreateView(props) {
63
63
  ? createFields.filter((f) => f !== "runNow")
64
64
  : [...createFields];
65
65
  const filteredFields = visibleFields.filter((f) => {
66
- if (f === "command" || f === "cwd")
66
+ if (f === "command")
67
67
  return isInline;
68
68
  if (f === "taskId")
69
69
  return !isInline;
@@ -202,12 +202,18 @@ export function CreateView(props) {
202
202
  setError(t("errors.descriptionEmpty"));
203
203
  return;
204
204
  }
205
+ const cwd = current.cwd.trim();
206
+ if (cwd && !fs.existsSync(cwd)) {
207
+ setError(t("board.cwdMissing", { cwd }));
208
+ return;
209
+ }
205
210
  const built = buildLoopOptions(current.interval.trim(), {
206
211
  now: props.mode === "create" && current.runNow === "y",
207
212
  maxRuns: current.maxRuns.trim() || null,
208
213
  verbose: false,
209
214
  description: current.description.trim(),
210
215
  taskId: current.taskId,
216
+ cwd,
211
217
  });
212
218
  built.options.projectId = current.project || "default";
213
219
  const request = props.mode === "edit" && props.editId
@@ -72,7 +72,7 @@ export function TaskInspector(props) {
72
72
  return (_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { backgroundColor: "#0b0b0b" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.inspectorEmpty") }) }));
73
73
  }
74
74
  const cmd = commandLine(task.command, task.commandArgs);
75
- return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", task.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelName") }), " ", task.name] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldDir") }), " ", task.cwd || t("board.inherit")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnSuccess") }), " ", task.onSuccessTaskId ?? t("board.taskNone")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnFailure") }), " ", task.onFailureTaskId ?? t("board.taskNone")] })] }));
75
+ return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", task.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelName") }), " ", task.name] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnSuccess") }), " ", task.onSuccessTaskId ?? t("board.taskNone")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnFailure") }), " ", task.onFailureTaskId ?? t("board.taskNone")] })] }));
76
76
  }
77
77
  export function TaskActionButtons(props) {
78
78
  const { task, focused, selectedAction, selectable = true, onAction } = props;
@@ -80,16 +80,17 @@ export function TaskActionButtons(props) {
80
80
  return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, backgroundColor: "#0b0b0b", justifyContent: "center", alignItems: "center" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.noActions") }) }));
81
81
  }
82
82
  const allActions = [
83
- { key: "select", label: t("board.taskActionSelect") },
84
- { key: "edit", label: t("board.taskActionEdit") },
85
- { key: "delete", label: t("board.taskActionDelete") },
83
+ { key: "select", label: t("board.taskActionSelect"), hotkey: "s" },
84
+ { key: "edit", label: t("board.taskActionEdit"), hotkey: "e" },
85
+ { key: "delete", label: t("board.taskActionDelete"), hotkey: "d" },
86
86
  ];
87
87
  const actions = selectable ? allActions : allActions.filter((a) => a.key !== "select");
88
- return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center" }, children: actions.map((action, i) => (_jsx(TaskActionButton, { label: action.label, selected: focused && selectedAction === i, onMouseDown: () => onAction(action.key) }, action.key))) }));
88
+ return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center" }, children: actions.map((action, i) => (_jsx(TaskActionButton, { label: action.label, hotkey: action.hotkey, selected: focused && selectedAction === i, onMouseDown: () => onAction(action.key) }, action.key))) }));
89
89
  }
90
90
  function TaskActionButton(props) {
91
91
  const { isHovered, hoverProps } = useHoverState();
92
92
  const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
93
93
  const fg = props.selected ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af";
94
- return (_jsx("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) }) }));
94
+ const keycapFg = props.selected ? "#93c5fd" : "#6b7280";
95
+ return (_jsxs("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1, flexDirection: "row", alignItems: "center" }, ...hoverProps, children: [_jsxs("text", { fg: keycapFg, children: ["[", props.hotkey, "] "] }), _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) })] }));
95
96
  }
@@ -102,7 +102,6 @@ export function TaskForm(props) {
102
102
  name: current.name.trim() || command,
103
103
  command,
104
104
  commandArgs,
105
- cwd: props.editTask.cwd,
106
105
  onSuccessTaskId,
107
106
  onFailureTaskId,
108
107
  });
@@ -115,7 +114,6 @@ export function TaskForm(props) {
115
114
  name: current.name.trim() || command,
116
115
  command,
117
116
  commandArgs,
118
- cwd: "",
119
117
  onSuccessTaskId,
120
118
  onFailureTaskId,
121
119
  });
@@ -91,10 +91,18 @@ export function useTaskKeybindings(params) {
91
91
  onTaskAction("edit");
92
92
  return;
93
93
  }
94
+ if (name === "d" && tasks[taskSelectedIndex]) {
95
+ onTaskAction("delete");
96
+ return;
97
+ }
94
98
  if (name === "delete" && tasks[taskSelectedIndex]) {
95
99
  onTaskAction("delete");
96
100
  return;
97
101
  }
102
+ if (selectable && name === "s" && tasks[taskSelectedIndex]) {
103
+ onTaskAction("select");
104
+ return;
105
+ }
98
106
  }
99
107
  if (taskFocusedPanel === "actions") {
100
108
  if (name === "up" || name === "k") {
@@ -109,6 +117,14 @@ export function useTaskKeybindings(params) {
109
117
  onTaskAction(taskActions[taskSelectedAction] ?? "select");
110
118
  return;
111
119
  }
120
+ if (name === "d") {
121
+ onTaskAction("delete");
122
+ return;
123
+ }
124
+ if (selectable && name === "s") {
125
+ onTaskAction("select");
126
+ return;
127
+ }
112
128
  }
113
129
  });
114
130
  }
@@ -21,7 +21,7 @@ export const PROJECT_COLORS = {
21
21
  pink: "#f472b6",
22
22
  };
23
23
  export const PROJECT_COLOR_KEYS = Object.keys(PROJECT_COLORS);
24
- // Entity theme colors used for header action buttons and view accents
24
+ // Entity theme colors - used for header action buttons and view accents
25
25
  export const ENTITY_COLORS = {
26
26
  loop: "#38bdf8", // cyan/blue
27
27
  task: "#a78bfa", // purple
@@ -309,7 +309,7 @@ export class LoopController extends EventEmitter {
309
309
  const task = this.options.taskId ? this.taskResolver(this.options.taskId) : null;
310
310
  const command = task?.command ?? this.options.command;
311
311
  const commandArgs = task?.commandArgs ?? this.options.commandArgs;
312
- const cwd = task?.cwd ?? this.options.cwd;
312
+ const cwd = this.options.cwd;
313
313
  const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount);
314
314
  this.runAbortController = null;
315
315
  this.lastExitCode = result.exitCode;
@@ -354,7 +354,7 @@ export class LoopController extends EventEmitter {
354
354
  chainGroupId,
355
355
  chainName: chainTask.name,
356
356
  });
357
- const chainResult = await executeCommand(chainTask.command, chainTask.commandArgs, chainTask.cwd, this.logStream, signal, this.runCount);
357
+ const chainResult = await executeCommand(chainTask.command, chainTask.commandArgs, this.options.cwd, this.logStream, signal, this.runCount);
358
358
  const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
359
359
  const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running");
360
360
  if (chainRecord) {
@@ -27,7 +27,7 @@ export class LoopManager {
27
27
  }
28
28
  let taskId = meta.taskId;
29
29
  if (!taskId && meta.command) {
30
- const task = this.taskManager.createInline(meta.command, meta.commandArgs, meta.cwd ?? "");
30
+ const task = this.taskManager.createInline(meta.command, meta.commandArgs);
31
31
  taskId = task.id;
32
32
  meta.taskId = taskId;
33
33
  saveLoop(meta);
@@ -245,7 +245,7 @@ export class LoopManager {
245
245
  ...runtime,
246
246
  command: task?.command ?? options.command,
247
247
  commandArgs: task?.commandArgs ?? options.commandArgs,
248
- cwd: task?.cwd ?? options.cwd,
248
+ cwd: options.cwd || "",
249
249
  interval: options.interval,
250
250
  intervalHuman,
251
251
  immediate: options.immediate,
@@ -43,7 +43,7 @@ export class TaskManager {
43
43
  deleteTaskState(id);
44
44
  return true;
45
45
  }
46
- createInline(command, commandArgs, cwd) {
46
+ createInline(command, commandArgs) {
47
47
  const id = crypto.randomUUID().slice(0, 8);
48
48
  const name = [command, ...commandArgs].join(" ").slice(0, 40);
49
49
  return this.create({
@@ -51,7 +51,6 @@ export class TaskManager {
51
51
  name,
52
52
  command,
53
53
  commandArgs,
54
- cwd,
55
54
  onSuccessTaskId: null,
56
55
  onFailureTaskId: null,
57
56
  });
package/dist/i18n/en.json CHANGED
@@ -62,9 +62,9 @@
62
62
  "cli.projectColorSet": "Project \"{name}\" color set to {color}",
63
63
  "cli.projectDeleted": "Project \"{name}\" deleted (loops moved to Default)",
64
64
  "cli.projectNotFound": "Project not found: {name}",
65
- "cli.projectAmbiguous": "Multiple projects named \"{name}\" use the id instead",
65
+ "cli.projectAmbiguous": "Multiple projects named \"{name}\" - use the id instead",
66
66
  "cli.projectInvalidColor": "Invalid color \"{color}\". Valid names: {valid}, or a #rrggbb hex",
67
- "loop.runHeader": "\n[Run #{runNumber} {timestamp}]\n",
67
+ "loop.runHeader": "\n[Run #{runNumber} - {timestamp}]\n",
68
68
  "loop.exitMarker": "[exit {code} · {duration}]\n",
69
69
  "loop.cwdMissingLog": "[error] working directory does not exist: {cwd}\n",
70
70
  "loop.cwdMissing": "Working directory does not exist: {cwd}",
@@ -100,8 +100,8 @@
100
100
  "errors.resumeFailed": "resume failed",
101
101
  "errors.forceRunFailed": "force run failed",
102
102
  "errors.deleteFailed": "delete failed",
103
- "errors.maxRunsReached": "Max runs reached edit the loop and increase 'Max runs' to continue",
104
- "errors.triggerWhileRunning": "Loop is already running force run skipped",
103
+ "errors.maxRunsReached": "Max runs reached - edit the loop and increase 'Max runs' to continue",
104
+ "errors.triggerWhileRunning": "Loop is already running - force run skipped",
105
105
  "format.dash": "-",
106
106
  "format.justNow": "just now",
107
107
  "format.secsAgo": "{secs}s ago",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "1.4.0",
4
- "description": "Run commands on repeat. Manage them from a terminal board.",
3
+ "version": "1.4.1",
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": {
7
7
  "loop-task": "dist/entry.js"
@@ -29,14 +29,24 @@
29
29
  "keywords": [
30
30
  "cli",
31
31
  "loop",
32
+ "loop-engineering",
32
33
  "repeat",
33
34
  "interval",
34
35
  "schedule",
35
36
  "timer",
36
37
  "cron",
37
38
  "automation",
39
+ "automations",
38
40
  "devops",
39
- "agent"
41
+ "agent",
42
+ "ai-agent",
43
+ "coding-agent",
44
+ "agent-automation",
45
+ "claude-code",
46
+ "codex",
47
+ "opencode",
48
+ "background-tasks",
49
+ "scheduler"
40
50
  ],
41
51
  "author": "Quique Fdez Guerra",
42
52
  "license": "MIT",