loop-task 1.4.5 → 1.5.0
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 +76 -0
- package/dist/board/App.js +16 -7
- package/dist/board/components/ActionButtons.js +12 -12
- package/dist/board/components/ContextHelpModal.js +22 -0
- package/dist/board/components/CreateForm.js +3 -14
- package/dist/board/components/Navigator.js +3 -3
- package/dist/board/components/SearchSelect.js +99 -0
- package/dist/board/components/TaskForm.js +5 -2
- package/dist/board/hooks/useInputShortcuts.js +29 -10
- package/dist/board/hooks/useTaskKeybindings.js +6 -1
- package/dist/board/toast.js +1 -1
- package/dist/cli.js +47 -0
- package/dist/config/constants.js +2 -0
- package/dist/core/command-runner.js +33 -3
- package/dist/core/context-parser.js +55 -0
- package/dist/core/loop-controller.js +34 -4
- package/dist/core/scheduling.js +12 -0
- package/dist/core/template.js +3 -0
- package/dist/daemon/manager.js +12 -1
- package/dist/daemon/server.js +5 -0
- package/dist/daemon/spawner.js +1 -1
- package/dist/entry.js +0 -0
- package/dist/i18n/en.json +8 -1
- package/dist/loop-config.js +1 -0
- package/package.json +73 -74
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 |
|
|
@@ -248,6 +252,78 @@ loop-task (board) ──IPC──► daemon ──► loop 1 ──► task (com
|
|
|
248
252
|
- **Persistent** - loop and task state is saved after every run; survives restarts
|
|
249
253
|
- **Graceful shutdown** - background loops are daemon-managed; foreground loops finish the current execution on Ctrl+C
|
|
250
254
|
|
|
255
|
+
## Chain Context Sharing
|
|
256
|
+
|
|
257
|
+
When tasks are arranged in a chain (on-success or on-failure), context flows between them automatically. This lets later tasks reference output from earlier ones without custom glue.
|
|
258
|
+
|
|
259
|
+
### How it works
|
|
260
|
+
|
|
261
|
+
1. **Auto-capture** - stdout from every task in the chain is captured before the next task starts.
|
|
262
|
+
2. **Parse rules** - captured output is parsed by content type:
|
|
263
|
+
- **JSON object** (`{"key": "value"}`) - each key is merged into the shared context.
|
|
264
|
+
- **JSONL** (one JSON object per line) - each line's keys are merged in order.
|
|
265
|
+
- **Plain text** - stored under a single `output` key.
|
|
266
|
+
- **Empty output** - no change to context.
|
|
267
|
+
3. **Template interpolation** - use `{{key}}` in the command or arguments of any task. Before spawning, `{{key}}` is replaced with the current value of `key` from the shared context.
|
|
268
|
+
4. **Merge semantics** - keys accumulate across the chain. Task 1 produces `{ "id": "42" }`, task 2 can use `{{id}}` and also add `{ "status": "ok" }`. Task 3 sees both.
|
|
269
|
+
5. **Output clobbering** - plain text tasks overwrite the `output` key. Use JSON with named keys when data must survive across multiple downstream tasks.
|
|
270
|
+
6. **Context lifecycle** - context is built fresh each loop iteration and exists only in memory. It is never persisted to disk.
|
|
271
|
+
|
|
272
|
+
### Example: Issue Refinement Chain
|
|
273
|
+
|
|
274
|
+
A four-task chain that finds an issue, marks it in-progress, rewrites it with AI, and relabels it - all without re-querying:
|
|
275
|
+
|
|
276
|
+
**Task 1** (primary): Find an issue to refine
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
gh issue list --label "to refine" --limit 1 --json number,title,body --jq '{number: .[0].number, title: .[0].title, body: .[0].body}'
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
stdout: `{"number":123,"title":"Fix login","body":"It doesn't work"}`
|
|
283
|
+
context: `{ number: 123, title: "Fix login", body: "It doesn't work" }`
|
|
284
|
+
|
|
285
|
+
**Task 2** (chain, onSuccess): Mark as in-progress
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
gh issue edit {{number}} --add-label "refining" --remove-label "to refine"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
interpolated: `gh issue edit 123 --add-label "refining" --remove-label "to refine"`
|
|
292
|
+
|
|
293
|
+
**Task 3** (chain, onSuccess): Rewrite with AI
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
opencode run "Rewrite this GitHub issue as a detailed user story using project context and return only JSON with fields title and body." --model "opencode/big-pickle"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
stdout: `{"title":"As a user, I want to log in securely","body":"## Acceptance Criteria\n- Login form validates email\n- ..."}`
|
|
300
|
+
context: `{ number: 123, title: "As a user, I want to log in securely", body: "## Acceptance Criteria\n- ..." }`
|
|
301
|
+
|
|
302
|
+
**Task 4** (chain, onSuccess): Apply the rewrite and relabel
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
gh issue edit {{number}} --title "{{title}}" --body "{{body}}" --remove-label "refining" --add-label "to implement"
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
interpolated: `gh issue edit 123 --title "As a user, I want to log in securely" --body "## Acceptance Criteria\n- ..." --remove-label "refining" --add-label "to implement"`
|
|
309
|
+
|
|
310
|
+
### How it works
|
|
311
|
+
|
|
312
|
+
1. Task 1 queries the issue and emits a JSON object with `number`, `title`, and `body` via `--jq`. The primary task cannot use `{{key}}` interpolation because the chain context is empty when it runs.
|
|
313
|
+
2. Task 2 receives `{{number}}` interpolated from task 1's context. It relabels the issue from "to refine" to "refining" - no re-query needed.
|
|
314
|
+
3. Task 3 runs opencode, which finds the issue by the "refining" label and rewrites it. It outputs a new JSON object with updated `title` and `body`. Since it uses the same key names, the context is updated with the new values (merge with last-writer-wins).
|
|
315
|
+
4. Task 4 receives `{{number}}` (still 123 from task 1), `{{title}}` and `{{body}}` (now the rewritten versions from task 3). It applies the edits and relabels the issue as "to implement" - no re-query needed.
|
|
316
|
+
|
|
317
|
+
### Wrapping values with --jq
|
|
318
|
+
|
|
319
|
+
To avoid the plain-text `output` clobbering, wrap any value in a named JSON key using `--jq` (requires `--json` before `--jq`):
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
gh issue list --label "to refine" --json number,title --jq '{number: .[0].number, title: .[0].title}'
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
This stores `{ "number": 123, "title": "Fix login" }` in context instead of overwriting `output`.
|
|
326
|
+
|
|
251
327
|
## Development
|
|
252
328
|
|
|
253
329
|
Requires [Bun](https://bun.sh) >= 1.2 for package management and the board, and [Node.js](https://nodejs.org) >= 20 for the CLI and daemon.
|
package/dist/board/App.js
CHANGED
|
@@ -14,6 +14,7 @@ import { Inspector } from "./components/Inspector.js";
|
|
|
14
14
|
import { RunHistory } from "./components/RunHistory.js";
|
|
15
15
|
import { ActionButtons } from "./components/ActionButtons.js";
|
|
16
16
|
import { HelpModal } from "./components/HelpModal.js";
|
|
17
|
+
import { ContextHelpModal } from "./components/ContextHelpModal.js";
|
|
17
18
|
import { Footer } from "./components/Footer.js";
|
|
18
19
|
import { ConfirmModal } from "./components/ConfirmModal.js";
|
|
19
20
|
import { CreateView, createInitialValues } from "./components/CreateForm.js";
|
|
@@ -26,7 +27,7 @@ import { ProjectsPage } from "./components/ProjectsPage.js";
|
|
|
26
27
|
import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, triggerLoop, listTasks, deleteTask, listProjects } from "./daemon.js";
|
|
27
28
|
import { useBreakpoint } from "./hooks/useBreakpoint.js";
|
|
28
29
|
import { useRouter } from "./router.js";
|
|
29
|
-
|
|
30
|
+
import { POLL_MS } from "../config/constants.js";
|
|
30
31
|
const VIEW_TO_MODE = {
|
|
31
32
|
create: "create",
|
|
32
33
|
"task-create": "task",
|
|
@@ -58,6 +59,7 @@ export function App(props) {
|
|
|
58
59
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
59
60
|
const [searchActive, setSearchActive] = useState(false);
|
|
60
61
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
62
|
+
const [contextHelpOpen, setContextHelpOpen] = useState(false);
|
|
61
63
|
const [confirm, setConfirm] = useState(null);
|
|
62
64
|
const [confirmChoice, setConfirmChoice] = useState(0);
|
|
63
65
|
const [editTarget, setEditTarget] = useState(null);
|
|
@@ -117,14 +119,20 @@ export function App(props) {
|
|
|
117
119
|
}
|
|
118
120
|
catch { /* ignore */ }
|
|
119
121
|
}
|
|
120
|
-
useState(() => { void refreshTasks(); });
|
|
121
122
|
async function refreshProjects() {
|
|
122
123
|
try {
|
|
123
124
|
setProjects(await listProjects());
|
|
124
125
|
}
|
|
125
126
|
catch { /* ignore */ }
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
+
useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const timer = setInterval(() => {
|
|
131
|
+
void refreshTasks();
|
|
132
|
+
void refreshProjects();
|
|
133
|
+
}, POLL_MS);
|
|
134
|
+
return () => clearInterval(timer);
|
|
135
|
+
}, []);
|
|
128
136
|
useEffect(() => {
|
|
129
137
|
try {
|
|
130
138
|
localStorage.setItem("loop-current-project", currentProjectId);
|
|
@@ -135,7 +143,7 @@ export function App(props) {
|
|
|
135
143
|
return async () => {
|
|
136
144
|
try {
|
|
137
145
|
await action();
|
|
138
|
-
|
|
146
|
+
void refresh();
|
|
139
147
|
pushToast("success", label);
|
|
140
148
|
}
|
|
141
149
|
catch (error) {
|
|
@@ -266,7 +274,7 @@ export function App(props) {
|
|
|
266
274
|
setPendingTaskSelection(null);
|
|
267
275
|
pop();
|
|
268
276
|
pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
|
|
269
|
-
|
|
277
|
+
void refresh();
|
|
270
278
|
};
|
|
271
279
|
const onTaskDone = (updated, id) => {
|
|
272
280
|
setEditTask(null);
|
|
@@ -309,7 +317,7 @@ export function App(props) {
|
|
|
309
317
|
onOpenRunLog: handleOpenRunLog,
|
|
310
318
|
refreshTasks,
|
|
311
319
|
onViewTasks: () => { void refreshTasks(); push("task-list"); },
|
|
312
|
-
onViewProjects: () => push("projects"),
|
|
320
|
+
onViewProjects: () => { void refreshProjects(); push("projects"); },
|
|
313
321
|
onViewLoops: () => replace("board"),
|
|
314
322
|
onAddLoop: () => { setEditTarget(null); push("create"); },
|
|
315
323
|
onAddTask: () => { setEditTask(null); push("task-create"); },
|
|
@@ -335,6 +343,7 @@ export function App(props) {
|
|
|
335
343
|
onEnterHeader: (direction) => {
|
|
336
344
|
setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
|
|
337
345
|
},
|
|
346
|
+
onToggleContextHelp: () => setContextHelpOpen((v) => !v),
|
|
338
347
|
selectable: stack.includes("create") || stack.includes("task-edit"),
|
|
339
348
|
});
|
|
340
349
|
const counts = {
|
|
@@ -355,6 +364,6 @@ export function App(props) {
|
|
|
355
364
|
} })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: focusedPanel === "loops", projects: projects, onSelect: (index) => { setSelectedIndex(index); setFocusedPanel("loops"); }, onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
|
|
356
365
|
setEditTarget(loop);
|
|
357
366
|
push("create");
|
|
358
|
-
} } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
|
|
367
|
+
} } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, contextHelpOpen ? _jsx(ContextHelpModal, { onClose: () => setContextHelpOpen(false) }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
|
|
359
368
|
}
|
|
360
369
|
const TASK_FORM_VIEWS = new Set(["task-create", "task-edit"]);
|
|
@@ -2,22 +2,22 @@ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
|
|
|
2
2
|
import { t } from "../../i18n/index.js";
|
|
3
3
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
4
4
|
import { HOVER_BG } from "../../config/constants.js";
|
|
5
|
-
|
|
5
|
+
const STOP_ACTION = { key: "stop", label: t("board.actionStop") };
|
|
6
|
+
const PAUSE_ACTION = { key: "pause", label: t("board.actionPause") };
|
|
7
|
+
const PLAY_ACTION = { key: "play", label: t("board.actionPlay") };
|
|
8
|
+
const STATUS_ACTIONS = {
|
|
9
|
+
running: [STOP_ACTION],
|
|
10
|
+
waiting: [PAUSE_ACTION, STOP_ACTION],
|
|
11
|
+
paused: [STOP_ACTION, PLAY_ACTION],
|
|
12
|
+
idle: [PLAY_ACTION],
|
|
13
|
+
stopped: [PLAY_ACTION],
|
|
14
|
+
};
|
|
15
|
+
function getActions(status) {
|
|
6
16
|
const actions = [
|
|
7
17
|
{ key: "edit", label: t("board.actionEdit") },
|
|
8
18
|
{ key: "delete", label: t("board.actionDelete") },
|
|
9
19
|
];
|
|
10
|
-
|
|
11
|
-
actions.push({ key: "pause", label: t("board.actionPause") });
|
|
12
|
-
actions.push({ key: "stop", label: t("board.actionStop") });
|
|
13
|
-
}
|
|
14
|
-
else if (status === "paused") {
|
|
15
|
-
actions.push({ key: "stop", label: t("board.actionStop") });
|
|
16
|
-
actions.push({ key: "play", label: t("board.actionPlay") });
|
|
17
|
-
}
|
|
18
|
-
else if (status === "idle" || status === "stopped") {
|
|
19
|
-
actions.push({ key: "play", label: t("board.actionPlay") });
|
|
20
|
-
}
|
|
20
|
+
actions.push(...STATUS_ACTIONS[status]);
|
|
21
21
|
if (status !== "running") {
|
|
22
22
|
actions.push({ key: "clone", label: t("board.actionClone") });
|
|
23
23
|
actions.push({ key: "trigger", label: t("board.actionTrigger") });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
export function ContextHelpModal(props) {
|
|
5
|
+
const { onClose } = props;
|
|
6
|
+
const { width } = useTerminalDimensions();
|
|
7
|
+
return (_jsx("box", { onMouseDown: onClose, style: {
|
|
8
|
+
position: "absolute",
|
|
9
|
+
top: 0,
|
|
10
|
+
left: 0,
|
|
11
|
+
width: "100%",
|
|
12
|
+
height: "100%",
|
|
13
|
+
justifyContent: "center",
|
|
14
|
+
alignItems: "center",
|
|
15
|
+
zIndex: 90,
|
|
16
|
+
}, children: _jsxs("box", { title: t("context.helpTitle"), border: true, style: {
|
|
17
|
+
flexDirection: "column",
|
|
18
|
+
padding: 1,
|
|
19
|
+
minWidth: Math.min(72, width - 4),
|
|
20
|
+
backgroundColor: "#111827",
|
|
21
|
+
}, children: [_jsx("text", { fg: "#e5e7eb", children: t("context.helpRules") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#38bdf8", children: "{{key}} Templates" }), _jsx("text", { fg: "#e5e7eb", children: t("context.helpTemplates") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#f59e0b", children: "Caveat" }), _jsx("text", { fg: "#e5e7eb", children: t("context.helpCaveat") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#34d399", children: "jq Tip" }), _jsx("text", { fg: "#e5e7eb", children: t("context.helpJqTip") }), _jsx("text", { children: " " }), _jsx("text", { fg: "#9ca3af", children: "Press any key or click to close" })] }) }));
|
|
22
|
+
}
|
|
@@ -8,6 +8,7 @@ import { commandLine } from "../format.js";
|
|
|
8
8
|
import { createLoop, updateLoop, listTasks } from "../daemon.js";
|
|
9
9
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
10
10
|
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
11
|
+
import { SearchSelect } from "./SearchSelect.js";
|
|
11
12
|
import { HOVER_BG } from "../../config/constants.js";
|
|
12
13
|
export const TASK_MODE_INLINE = "inline";
|
|
13
14
|
export const TASK_MODE_EXISTING = "existing";
|
|
@@ -104,17 +105,8 @@ export function CreateView(props) {
|
|
|
104
105
|
}
|
|
105
106
|
if (key.name === "up" || key.name === "down") {
|
|
106
107
|
const field = filteredFields[focusIndex];
|
|
107
|
-
if (field === "project"
|
|
108
|
-
const currentIdx = projectOptions.findIndex((p) => p.value === valuesRef.current.project);
|
|
109
|
-
const nextIdx = key.name === "down"
|
|
110
|
-
? (currentIdx + 1) % projectOptions.length
|
|
111
|
-
: (currentIdx - 1 + projectOptions.length) % projectOptions.length;
|
|
112
|
-
const next = projectOptions[nextIdx];
|
|
113
|
-
if (next)
|
|
114
|
-
updateValues({ ...valuesRef.current, project: next.value });
|
|
115
|
-
key.preventDefault();
|
|
108
|
+
if (field === "project")
|
|
116
109
|
return;
|
|
117
|
-
}
|
|
118
110
|
setFocusIndex((i) => {
|
|
119
111
|
const next = key.name === "up" ? i - 1 : i + 1;
|
|
120
112
|
if (next < 0)
|
|
@@ -301,10 +293,7 @@ function FormRow(props) {
|
|
|
301
293
|
return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, [field]: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: isActive ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af", children: _jsx("strong", { children: opt.name }) }) }, opt.value));
|
|
302
294
|
}) })) : isTaskButton ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => { setFocusIndex(index); onChooseTask(); }, children: _jsx("text", { fg: values.taskId ? "#4ade80" : "#9ca3af", children: values.taskId
|
|
303
295
|
? t("board.selectedTask", { name: selectedTaskName ?? values.taskId })
|
|
304
|
-
: t("board.chooseTask") }) })) : isProjectField ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3,
|
|
305
|
-
const isActive = values.project === opt.value;
|
|
306
|
-
return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, project: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, children: _jsx("text", { fg: isActive ? "#ffffff" : "#9ca3af", children: `● ${opt.name}` }) }, opt.value));
|
|
307
|
-
}) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
296
|
+
: t("board.chooseTask") }) })) : isProjectField ? (_jsx(SearchSelect, { options: projectOptions, value: values.project, onChange: (v) => updateValues({ ...valuesRef.current, project: v }), focused: isFocused })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
308
297
|
if (index < fields.length - 1) {
|
|
309
298
|
setFocusIndex(index + 1);
|
|
310
299
|
}
|
|
@@ -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(
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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) +
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useRef, useState, useMemo } from "react";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
6
|
+
import { SEARCH_SELECT_HEIGHT } from "../../config/constants.js";
|
|
7
|
+
export function SearchSelect(props) {
|
|
8
|
+
const { options, value, onChange, focused, height } = props;
|
|
9
|
+
const placeholder = props.placeholder ?? t("board.searchSelectPlaceholder");
|
|
10
|
+
const maxHeight = height ?? SEARCH_SELECT_HEIGHT;
|
|
11
|
+
const inputRef = useRef(null);
|
|
12
|
+
const [filter, setFilter] = useState("");
|
|
13
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
14
|
+
useInputShortcuts(() => focused ? inputRef.current : null);
|
|
15
|
+
const filtered = useMemo(() => {
|
|
16
|
+
if (!filter)
|
|
17
|
+
return options;
|
|
18
|
+
const q = filter.toLowerCase();
|
|
19
|
+
return options.filter((o) => o.name.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
|
|
20
|
+
}, [options, filter]);
|
|
21
|
+
const currentIdx = filtered.findIndex((o) => o.value === value);
|
|
22
|
+
const safeSelected = currentIdx >= 0 ? currentIdx : selectedIndex;
|
|
23
|
+
const clampedSelected = Math.min(safeSelected, Math.max(0, filtered.length - 1));
|
|
24
|
+
useKeyboard((key) => {
|
|
25
|
+
if (!focused)
|
|
26
|
+
return;
|
|
27
|
+
const name = key.name;
|
|
28
|
+
if (name === "up" || name === "k") {
|
|
29
|
+
if (filtered.length > 0) {
|
|
30
|
+
setSelectedIndex((i) => i <= 0 ? filtered.length - 1 : i - 1);
|
|
31
|
+
}
|
|
32
|
+
key.preventDefault();
|
|
33
|
+
key.stopPropagation();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (name === "down" || name === "j") {
|
|
37
|
+
if (filtered.length > 0) {
|
|
38
|
+
setSelectedIndex((i) => i >= filtered.length - 1 ? 0 : i + 1);
|
|
39
|
+
}
|
|
40
|
+
key.preventDefault();
|
|
41
|
+
key.stopPropagation();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (name === "return" || name === "enter") {
|
|
45
|
+
const option = filtered[clampedSelected];
|
|
46
|
+
if (option) {
|
|
47
|
+
onChange(option.value);
|
|
48
|
+
}
|
|
49
|
+
key.preventDefault();
|
|
50
|
+
key.stopPropagation();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (name === "escape") {
|
|
54
|
+
if (filter) {
|
|
55
|
+
setFilter("");
|
|
56
|
+
setSelectedIndex(0);
|
|
57
|
+
}
|
|
58
|
+
key.preventDefault();
|
|
59
|
+
key.stopPropagation();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (name === "backspace") {
|
|
63
|
+
setFilter((f) => f.slice(0, -1));
|
|
64
|
+
setSelectedIndex(0);
|
|
65
|
+
key.preventDefault();
|
|
66
|
+
key.stopPropagation();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key.ctrl)
|
|
70
|
+
return;
|
|
71
|
+
if (name && name.length === 1 && /[a-z0-9 _\-./]/i.test(name)) {
|
|
72
|
+
setFilter((f) => f + name);
|
|
73
|
+
setSelectedIndex(0);
|
|
74
|
+
key.preventDefault();
|
|
75
|
+
key.stopPropagation();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.sequence && key.sequence.length === 1 && /[a-z0-9 _\-./]/i.test(key.sequence)) {
|
|
79
|
+
setFilter((f) => f + key.sequence);
|
|
80
|
+
setSelectedIndex(0);
|
|
81
|
+
key.preventDefault();
|
|
82
|
+
key.stopPropagation();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const listHeight = Math.min(filtered.length, maxHeight);
|
|
87
|
+
return (_jsxs("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { style: { height: 3, flexDirection: "row", alignItems: "center" }, children: [_jsx("text", { fg: "#6b7280", children: " / " }), _jsx("input", { ref: inputRef, focused: focused, value: filter, placeholder: placeholder, onInput: (v) => {
|
|
88
|
+
setFilter(v);
|
|
89
|
+
setSelectedIndex(0);
|
|
90
|
+
} })] }), _jsx("box", { style: { flexDirection: "column", height: listHeight }, children: filtered.map((option, i) => {
|
|
91
|
+
const isSelected = i === clampedSelected;
|
|
92
|
+
const isActive = option.value === value;
|
|
93
|
+
const prefix = isSelected ? "› " : " ";
|
|
94
|
+
const colorIndicator = option.color ? `\u25cf ` : "";
|
|
95
|
+
const bg = isSelected ? "#1e3a8a" : undefined;
|
|
96
|
+
const fg = isSelected ? "#ffffff" : isActive ? "#38bdf8" : "#9ca3af";
|
|
97
|
+
return (_jsx("box", { style: { flexDirection: "row", backgroundColor: bg }, children: _jsx("text", { fg: fg, children: `${prefix}${colorIndicator}${option.name}` }) }, option.value));
|
|
98
|
+
}) })] }));
|
|
99
|
+
}
|
|
@@ -7,6 +7,7 @@ import { createTask, updateTask, listTasks } from "../daemon.js";
|
|
|
7
7
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
8
8
|
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
9
9
|
import { HOVER_BG } from "../../config/constants.js";
|
|
10
|
+
import { SearchSelect } from "./SearchSelect.js";
|
|
10
11
|
const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
|
|
11
12
|
function taskInitialValues(task) {
|
|
12
13
|
if (!task) {
|
|
@@ -57,6 +58,9 @@ export function TaskForm(props) {
|
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
59
60
|
if (key.name === "up" || key.name === "down") {
|
|
61
|
+
const field = taskFields[focusIndex];
|
|
62
|
+
if (field === "onSuccessTaskId" || field === "onFailureTaskId")
|
|
63
|
+
return;
|
|
60
64
|
setFocusIndex((i) => {
|
|
61
65
|
const next = key.name === "up" ? i - 1 : i + 1;
|
|
62
66
|
if (next < 0)
|
|
@@ -155,8 +159,7 @@ function TaskFormRow(props) {
|
|
|
155
159
|
const isFocused = focusIndex === index;
|
|
156
160
|
const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
|
|
157
161
|
const selectOpts = isSelect ? chainOptions : [];
|
|
158
|
-
|
|
159
|
-
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
162
|
+
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx(SearchSelect, { options: selectOpts, value: values[field], onChange: (v) => updateValues({ ...valuesRef.current, [field]: v }), focused: isFocused })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
160
163
|
if (index < taskFields.length - 1) {
|
|
161
164
|
setFocusIndex(index + 1);
|
|
162
165
|
}
|
|
@@ -9,33 +9,52 @@ export function useInputShortcuts(getActiveInput) {
|
|
|
9
9
|
return;
|
|
10
10
|
const name = key.name;
|
|
11
11
|
if (name === "c") {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useKeyboard } from "@opentui/react";
|
|
2
2
|
import { nextTaskPanel } from "../components/TaskBrowser.js";
|
|
3
3
|
export function useTaskKeybindings(params) {
|
|
4
|
-
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, selectable = true, } = params;
|
|
4
|
+
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, onToggleContextHelp, selectable = true, } = params;
|
|
5
5
|
const taskActions = selectable
|
|
6
6
|
? ["select", "edit", "delete"]
|
|
7
7
|
: ["edit", "delete"];
|
|
@@ -36,6 +36,11 @@ export function useTaskKeybindings(params) {
|
|
|
36
36
|
key.preventDefault();
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
+
if (name === "?") {
|
|
40
|
+
onToggleContextHelp();
|
|
41
|
+
key.preventDefault();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
39
44
|
if (name === "n") {
|
|
40
45
|
onCreateTask();
|
|
41
46
|
key.preventDefault();
|
package/dist/board/toast.js
CHANGED
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
|
}
|
package/dist/config/constants.js
CHANGED
|
@@ -3,6 +3,7 @@ export const TOAST_TIMEOUT_MS = 3500;
|
|
|
3
3
|
export const TOAST_MAX = 4;
|
|
4
4
|
export const LOG_LINES_MAX = 500;
|
|
5
5
|
export const MAX_LOG_BYTES = 1024 * 1024;
|
|
6
|
+
export const MAX_CONTEXT_STDOUT_BYTES = 1024 * 1024;
|
|
6
7
|
export const MAX_LOG_GENERATIONS = 3;
|
|
7
8
|
export const SLEEP_CHUNK_MS = 200;
|
|
8
9
|
export const DAEMON_SPAWN_TIMEOUT_MS = 5000;
|
|
@@ -11,6 +12,7 @@ export const IPC_TIMEOUT_MS = 10000;
|
|
|
11
12
|
export const LOG_TAIL_DEFAULT = 50;
|
|
12
13
|
export const BOARD_BREAKPOINT_WIDTH = 80;
|
|
13
14
|
export const HEADER_COMPACT_WIDTH = 60;
|
|
15
|
+
export const SEARCH_SELECT_HEIGHT = 6;
|
|
14
16
|
export const HOVER_BG = "#1e3a5f";
|
|
15
17
|
export const PROJECT_COLORS = {
|
|
16
18
|
white: "#ffffff",
|
|
@@ -2,12 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import { execa } from "execa";
|
|
3
3
|
import { formatDuration } from "../duration.js";
|
|
4
4
|
import { t } from "../i18n/index.js";
|
|
5
|
+
import { MAX_CONTEXT_STDOUT_BYTES } from "../config/constants.js";
|
|
5
6
|
export function extractExitCode(error) {
|
|
6
7
|
return error && typeof error === "object" && "exitCode" in error
|
|
7
8
|
? error.exitCode
|
|
8
9
|
: 1;
|
|
9
10
|
}
|
|
10
|
-
export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber) {
|
|
11
|
+
export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber, captureStdout = false) {
|
|
11
12
|
const startedAt = new Date();
|
|
12
13
|
const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
|
|
13
14
|
logStream.write(header);
|
|
@@ -25,8 +26,25 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
25
26
|
cancelSignal: signal,
|
|
26
27
|
shell: true,
|
|
27
28
|
});
|
|
29
|
+
const stdoutChunks = [];
|
|
30
|
+
let stdoutTruncated = false;
|
|
31
|
+
let stdoutBytes = 0;
|
|
28
32
|
child.stdout.on("data", (chunk) => {
|
|
29
33
|
logStream.write(chunk);
|
|
34
|
+
if (captureStdout && !stdoutTruncated) {
|
|
35
|
+
const chunkStr = chunk.toString();
|
|
36
|
+
const chunkLen = Buffer.byteLength(chunkStr, "utf-8");
|
|
37
|
+
if (stdoutBytes + chunkLen > MAX_CONTEXT_STDOUT_BYTES) {
|
|
38
|
+
const remaining = MAX_CONTEXT_STDOUT_BYTES - stdoutBytes;
|
|
39
|
+
stdoutChunks.push(chunkStr.slice(0, remaining));
|
|
40
|
+
stdoutTruncated = true;
|
|
41
|
+
logStream.write(t("context.truncationWarning"));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
stdoutChunks.push(chunkStr);
|
|
45
|
+
stdoutBytes += chunkLen;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
});
|
|
31
49
|
child.stderr.on("data", (chunk) => {
|
|
32
50
|
logStream.write(chunk);
|
|
@@ -36,14 +54,26 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
|
|
|
36
54
|
const endedAt = new Date();
|
|
37
55
|
const duration = endedAt.getTime() - startedAt.getTime();
|
|
38
56
|
logStream.write(t("loop.exitMarker", { code: String(result.exitCode), duration: formatDuration(duration) }));
|
|
39
|
-
return {
|
|
57
|
+
return {
|
|
58
|
+
exitCode: result.exitCode ?? 0,
|
|
59
|
+
duration,
|
|
60
|
+
startedAt,
|
|
61
|
+
endedAt,
|
|
62
|
+
...(captureStdout ? { stdout: stdoutChunks.join("") } : {}),
|
|
63
|
+
};
|
|
40
64
|
}
|
|
41
65
|
catch (error) {
|
|
42
66
|
const endedAt = new Date();
|
|
43
67
|
const duration = endedAt.getTime() - startedAt.getTime();
|
|
44
68
|
const exitCode = extractExitCode(error);
|
|
45
69
|
logStream.write(t("loop.exitMarker", { code: exitCode, duration: formatDuration(duration) }));
|
|
46
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
exitCode,
|
|
72
|
+
duration,
|
|
73
|
+
startedAt,
|
|
74
|
+
endedAt,
|
|
75
|
+
...(captureStdout ? { stdout: stdoutChunks.join("") } : {}),
|
|
76
|
+
};
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
export async function executeCommandForeground(command, commandArgs, logger, cwd = "") {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function isObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function parseStdout(raw) {
|
|
5
|
+
const trimmed = raw.trim();
|
|
6
|
+
if (trimmed.length === 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
let whole;
|
|
10
|
+
try {
|
|
11
|
+
whole = JSON.parse(trimmed);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
whole = undefined;
|
|
15
|
+
}
|
|
16
|
+
if (whole !== undefined) {
|
|
17
|
+
if (isObject(whole)) {
|
|
18
|
+
return whole;
|
|
19
|
+
}
|
|
20
|
+
if (typeof whole === "string" || typeof whole === "number" || typeof whole === "boolean" || whole === null) {
|
|
21
|
+
return { output: String(whole) };
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(whole)) {
|
|
24
|
+
return { output: trimmed };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const lines = trimmed.split("\n");
|
|
28
|
+
const parsedLines = [];
|
|
29
|
+
let jsonlSuccess = true;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const l = line.trim();
|
|
32
|
+
if (l.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
parsedLines.push(JSON.parse(l));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
jsonlSuccess = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (jsonlSuccess && parsedLines.length > 0) {
|
|
43
|
+
const result = {};
|
|
44
|
+
for (const pl of parsedLines) {
|
|
45
|
+
if (isObject(pl)) {
|
|
46
|
+
Object.assign(result, pl);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result.output = String(pl);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
return { output: trimmed };
|
|
55
|
+
}
|
|
@@ -5,7 +5,10 @@ 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";
|
|
10
|
+
import { parseStdout } from "./context-parser.js";
|
|
11
|
+
import { interpolate } from "./template.js";
|
|
9
12
|
export class LoopController extends EventEmitter {
|
|
10
13
|
abortController;
|
|
11
14
|
runAbortController = null;
|
|
@@ -116,7 +119,11 @@ export class LoopController extends EventEmitter {
|
|
|
116
119
|
this._resetSchedule = true;
|
|
117
120
|
this._paused = false;
|
|
118
121
|
this._status = "waiting";
|
|
119
|
-
|
|
122
|
+
const phase = this.options.offset !== null
|
|
123
|
+
? this.options.offset
|
|
124
|
+
: computePhase(this.id, this.options.interval);
|
|
125
|
+
const delay = alignToPhase(Date.now(), this.options.interval, phase);
|
|
126
|
+
this.nextRunAt = new Date(Date.now() + delay).toISOString();
|
|
120
127
|
if (this.resumeResolve) {
|
|
121
128
|
this.resumeResolve();
|
|
122
129
|
this.resumeResolve = null;
|
|
@@ -274,7 +281,14 @@ export class LoopController extends EventEmitter {
|
|
|
274
281
|
}
|
|
275
282
|
}
|
|
276
283
|
else if (isFirstRun && !this.options.immediate) {
|
|
277
|
-
const
|
|
284
|
+
const phase = this.options.offset !== null
|
|
285
|
+
? this.options.offset
|
|
286
|
+
: computePhase(this.id, this.options.interval);
|
|
287
|
+
const delay = alignToPhase(Date.now(), this.options.interval, phase);
|
|
288
|
+
if (delay > 0) {
|
|
289
|
+
this.nextRunAt = new Date(Date.now() + delay).toISOString();
|
|
290
|
+
}
|
|
291
|
+
const completed = await this.waitForDelay(delay, signal);
|
|
278
292
|
if (!completed) {
|
|
279
293
|
break;
|
|
280
294
|
}
|
|
@@ -311,8 +325,16 @@ export class LoopController extends EventEmitter {
|
|
|
311
325
|
const command = task?.command ?? this.options.command;
|
|
312
326
|
const commandArgs = task?.commandArgs ?? this.options.commandArgs;
|
|
313
327
|
const cwd = this.options.cwd;
|
|
314
|
-
const
|
|
328
|
+
const chainContext = {};
|
|
329
|
+
const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
|
|
330
|
+
const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, hasChainTasks);
|
|
315
331
|
this.runAbortController = null;
|
|
332
|
+
if (hasChainTasks && result.stdout) {
|
|
333
|
+
const parsed = parseStdout(result.stdout);
|
|
334
|
+
if (parsed !== null) {
|
|
335
|
+
Object.assign(chainContext, parsed);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
316
338
|
this.lastExitCode = result.exitCode;
|
|
317
339
|
this.lastDuration = result.duration;
|
|
318
340
|
const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
|
|
@@ -361,7 +383,15 @@ export class LoopController extends EventEmitter {
|
|
|
361
383
|
chainGroupId,
|
|
362
384
|
chainName: chainTask.name,
|
|
363
385
|
});
|
|
364
|
-
const
|
|
386
|
+
const interpolatedCommand = interpolate(chainTask.command, chainContext);
|
|
387
|
+
const interpolatedArgs = chainTask.commandArgs.map(a => interpolate(a, chainContext));
|
|
388
|
+
const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, this.options.cwd, this.logStream, signal, this.runCount, true);
|
|
389
|
+
if (chainResult.stdout) {
|
|
390
|
+
const parsed = parseStdout(chainResult.stdout);
|
|
391
|
+
if (parsed !== null) {
|
|
392
|
+
Object.assign(chainContext, parsed);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
365
395
|
const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
|
|
366
396
|
const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
|
|
367
397
|
if (chainRecord) {
|
|
@@ -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
|
+
}
|
package/dist/daemon/manager.js
CHANGED
|
@@ -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
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -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") });
|
package/dist/daemon/spawner.js
CHANGED
package/dist/entry.js
CHANGED
|
File without changes
|
package/dist/i18n/en.json
CHANGED
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
"board.idleLabel": " Stopped: ",
|
|
129
129
|
"board.searchTitle": " Search / ",
|
|
130
130
|
"board.searchPlaceholder": "type to filter, enter to apply",
|
|
131
|
+
"board.searchSelectPlaceholder": "type to filter...",
|
|
131
132
|
"board.searchEmpty": "press / to search",
|
|
132
133
|
"board.statusFilterTitle": " Status x ",
|
|
133
134
|
"board.sortTitle": " Order o ",
|
|
@@ -384,5 +385,11 @@
|
|
|
384
385
|
"project.error.deleteFailed": "Failed to delete project",
|
|
385
386
|
"project.toastCreated": "Project \"{name}\" created",
|
|
386
387
|
"project.toastUpdated": "Project \"{name}\" updated",
|
|
387
|
-
"project.toastDeleted": "Project \"{name}\" deleted"
|
|
388
|
+
"project.toastDeleted": "Project \"{name}\" deleted",
|
|
389
|
+
"context.truncationWarning": "stdout exceeded 1MB - context was truncated",
|
|
390
|
+
"context.helpTitle": "Chain Context Sharing",
|
|
391
|
+
"context.helpRules": "JSON output merges its keys. JSONL merges each line as a separate key. Plain text is stored under the \"output\" key. Empty output produces no change.",
|
|
392
|
+
"context.helpTemplates": "Use {{key}} in commands and args - replaced with context values from earlier tasks in the chain.",
|
|
393
|
+
"context.helpCaveat": "Plain text stdout overwrites the \"output\" key. Use JSON with named keys when data must survive across tasks.",
|
|
394
|
+
"context.helpJqTip": "Tip: use --jq '{key: .field}' to wrap any value in a named JSON key"
|
|
388
395
|
}
|
package/dist/loop-config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,75 +1,74 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "loop-task",
|
|
3
|
-
"version": "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
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"loop-task": "dist/entry.js"
|
|
8
|
-
},
|
|
9
|
-
"main": "dist/entry.js",
|
|
10
|
-
"files": [
|
|
11
|
-
"dist"
|
|
12
|
-
],
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "loop-task",
|
|
3
|
+
"version": "1.5.0",
|
|
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
|
}
|