loop-task 1.4.2 → 1.4.3

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/dist/board/App.js CHANGED
@@ -345,7 +345,7 @@ export function App(props) {
345
345
  }
346
346
  else {
347
347
  push("projects");
348
- } } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default") })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; } })) : (_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) {
348
+ } } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); setFocusedPanel("loops"); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); setTaskFocusedPanel("tasks"); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; } })) : (_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) {
349
349
  setEditTarget(loop);
350
350
  push("create");
351
351
  } } }), _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 })] }));
@@ -86,38 +86,14 @@ export function CreateView(props) {
86
86
  if (key.name === "tab") {
87
87
  setFocusIndex((i) => {
88
88
  const next = key.shift ? i - 1 : i + 1;
89
- return Math.max(0, Math.min(cancelIndex, next));
89
+ if (next < 0)
90
+ return cancelIndex;
91
+ if (next > cancelIndex)
92
+ return 0;
93
+ return next;
90
94
  });
91
95
  return;
92
96
  }
93
- if (key.name === "left" || key.name === "right") {
94
- if (focusIndex === saveIndex) {
95
- setFocusIndex(key.name === "right" ? cancelIndex : focusIndex - 1 >= 0 ? filteredFields.length - 1 : 0);
96
- return;
97
- }
98
- if (focusIndex === cancelIndex) {
99
- setFocusIndex(key.name === "right" ? 0 : saveIndex);
100
- return;
101
- }
102
- if (key.name === "right") {
103
- if (focusIndex + 1 < filteredFields.length) {
104
- setFocusIndex((i) => i + 1);
105
- }
106
- else {
107
- setFocusIndex(saveIndex);
108
- }
109
- return;
110
- }
111
- if (key.name === "left") {
112
- if (focusIndex > 0) {
113
- setFocusIndex((i) => i - 1);
114
- }
115
- else {
116
- setFocusIndex(cancelIndex);
117
- }
118
- return;
119
- }
120
- }
121
97
  if (key.name === "return" || key.name === "enter" || key.name === " ") {
122
98
  const field = filteredFields[focusIndex];
123
99
  if (field === "taskMode") {
@@ -1,11 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } 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
+ import { SearchBox } from "./SearchBox.js";
5
6
  export function FilterBar(props) {
6
- const { filters, sort, searchActive, focusedPanel, onStatusCycle, onSortCycle, onSelectProject, currentProjectName } = props;
7
+ const { filters, sort, searchActive, focusedPanel, onStatusCycle, onSortCycle, onSelectProject, currentProjectName, onQueryChange, onSearchActivate, onSearchDismiss } = props;
7
8
  const statusDisplay = filters.status === "waiting" ? "waiting" : filters.status;
8
- return (_jsxs("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: [filters.query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: filters.query || t("board.searchEmpty") })) }), onSelectProject ? (_jsx(ClickableBadge, { title: t("project.filterTitle"), text: currentProjectName ?? "Default", textColor: "#f59e0b", focused: focusedPanel === "project-filter", onMouseDown: onSelectProject, marginRight: 1 })) : null, _jsx(ClickableBadge, { title: t("board.statusFilterTitle"), text: statusDisplay, textColor: "#38bdf8", focused: focusedPanel === "status", onMouseDown: onStatusCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.sortTitle"), text: sort, textColor: "#a3e635", focused: focusedPanel === "sort", onMouseDown: onSortCycle })] }));
9
+ return (_jsxs("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(SearchBox, { query: filters.query, searchActive: searchActive, focused: focusedPanel === "search", onQueryChange: onQueryChange, onActivate: onSearchActivate, onDismiss: onSearchDismiss }), onSelectProject ? (_jsx(ClickableBadge, { title: t("project.filterTitle"), text: currentProjectName ?? "Default", textColor: "#f59e0b", focused: focusedPanel === "project-filter", onMouseDown: onSelectProject, marginRight: 1 })) : null, _jsx(ClickableBadge, { title: t("board.statusFilterTitle"), text: statusDisplay, textColor: "#38bdf8", focused: focusedPanel === "status", onMouseDown: onStatusCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.sortTitle"), text: sort, textColor: "#a3e635", focused: focusedPanel === "sort", onMouseDown: onSortCycle })] }));
9
10
  }
10
11
  function ClickableBadge(props) {
11
12
  const { isHovered, hoverProps } = useHoverState();
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ export function SearchBox(props) {
4
+ const { query, searchActive, focused, onQueryChange, onActivate, onDismiss } = props;
5
+ return (_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focused ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { focused: true, value: query, placeholder: t("board.searchPlaceholder"), onInput: (value) => onQueryChange(value), onSubmit: () => onDismiss() })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", onMouseDown: onActivate, children: query || t("board.searchEmpty") })) }));
6
+ }
@@ -1,6 +1,6 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
- import { t } from "../../i18n/index.js";
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { SearchBox } from "./SearchBox.js";
3
3
  export function TaskFilterBar(props) {
4
- const { query, searchActive, focusedPanel } = props;
5
- return (_jsx("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { flexGrow: 1, height: 3, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: query ? "#e5e7eb" : "#6b7280", children: [query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", children: query || t("board.searchEmpty") })) }) }));
4
+ const { query, searchActive, focusedPanel, onQueryChange, onSearchActivate, onSearchDismiss } = props;
5
+ return (_jsx("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("box", { style: { flexGrow: 1 }, children: _jsx(SearchBox, { query: query, searchActive: searchActive, focused: focusedPanel === "search", onQueryChange: onQueryChange, onActivate: onSearchActivate, onDismiss: onSearchDismiss }) }) }));
6
6
  }
@@ -40,38 +40,14 @@ export function TaskForm(props) {
40
40
  if (key.name === "tab") {
41
41
  setFocusIndex((i) => {
42
42
  const next = key.shift ? i - 1 : i + 1;
43
- return Math.max(0, Math.min(cancelIndex, next));
43
+ if (next < 0)
44
+ return cancelIndex;
45
+ if (next > cancelIndex)
46
+ return 0;
47
+ return next;
44
48
  });
45
49
  return;
46
50
  }
47
- if (key.name === "left" || key.name === "right") {
48
- if (focusIndex === saveIndex) {
49
- setFocusIndex(key.name === "right" ? cancelIndex : taskFields.length - 1);
50
- return;
51
- }
52
- if (focusIndex === cancelIndex) {
53
- setFocusIndex(key.name === "right" ? 0 : saveIndex);
54
- return;
55
- }
56
- if (key.name === "right") {
57
- if (focusIndex + 1 < taskFields.length) {
58
- setFocusIndex((i) => i + 1);
59
- }
60
- else {
61
- setFocusIndex(saveIndex);
62
- }
63
- return;
64
- }
65
- if (key.name === "left") {
66
- if (focusIndex > 0) {
67
- setFocusIndex((i) => i - 1);
68
- }
69
- else {
70
- setFocusIndex(cancelIndex);
71
- }
72
- return;
73
- }
74
- }
75
51
  if (key.name === "return" || key.name === "enter") {
76
52
  if (focusIndex === saveIndex) {
77
53
  void submit(valuesRef.current);
@@ -219,23 +219,10 @@ export function useBoardKeybindings(params) {
219
219
  }
220
220
  if (helpOpen)
221
221
  return;
222
- if (searchActive && (name === "escape" || name === "return" || name === "enter")) {
223
- OVERLAY_DISMISS.search({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
224
- return;
225
- }
226
222
  if (searchActive) {
227
- if (name === "left") {
223
+ if (name === "escape") {
228
224
  setSearchActive(false);
229
- setFocusedPanel("actions");
230
- setSelectedAction((selected ? getActionCount(selected.status) : 0) - 1);
231
- return;
232
- }
233
- if (name === "backspace") {
234
- setFilters((prev) => ({ ...prev, query: prev.query.slice(0, -1) }));
235
- return;
236
- }
237
- if (key.sequence && key.sequence.length === 1 && key.sequence >= " " && key.sequence <= "~") {
238
- setFilters((prev) => ({ ...prev, query: prev.query + key.sequence }));
225
+ setFocusedPanel("loops");
239
226
  return;
240
227
  }
241
228
  return;
@@ -18,25 +18,6 @@ export function useTaskKeybindings(params) {
18
18
  setTaskFocusedPanel("tasks");
19
19
  return;
20
20
  }
21
- if (name === "return" || name === "enter") {
22
- setTaskSearchActive(false);
23
- setTaskFocusedPanel("tasks");
24
- return;
25
- }
26
- if (name === "left") {
27
- setTaskSearchActive(false);
28
- setTaskFocusedPanel("actions");
29
- setTaskSelectedAction(taskActionCount - 1);
30
- return;
31
- }
32
- if (name === "backspace") {
33
- setTaskQuery((q) => q.slice(0, -1));
34
- return;
35
- }
36
- if (key.sequence && key.sequence.length === 1 && key.sequence >= " " && key.sequence <= "~") {
37
- setTaskQuery((q) => q + key.sequence);
38
- return;
39
- }
40
21
  return;
41
22
  }
42
23
  if (name === "escape") {
@@ -21,6 +21,15 @@ export function loopFile(id) {
21
21
  export function taskFile(id) {
22
22
  return path.join(getTasksDir(), `${id}.json`);
23
23
  }
24
+ export function loopsJson() {
25
+ return path.join(getDataDir(), "loops.json");
26
+ }
27
+ export function tasksJson() {
28
+ return path.join(getDataDir(), "tasks.json");
29
+ }
30
+ export function projectsJson() {
31
+ return path.join(getDataDir(), "projects.json");
32
+ }
24
33
  export function logFile(id) {
25
34
  return path.join(getLogsDir(), `${id}.log`);
26
35
  }
@@ -335,12 +335,15 @@ export class LoopController extends EventEmitter {
335
335
  }
336
336
  const chainTargetId = result.exitCode === 0 ? task?.onSuccessTaskId : task?.onFailureTaskId;
337
337
  if (chainTargetId) {
338
- const chainTask = this.taskResolver(chainTargetId);
339
- if (chainTask) {
340
- const chainGroupId = crypto.randomUUID().slice(0, 8);
341
- const mainRecord = this.runHistory[this.runHistory.length - 1];
342
- if (mainRecord)
343
- mainRecord.chainGroupId = chainGroupId;
338
+ const chainGroupId = crypto.randomUUID().slice(0, 8);
339
+ const mainRecord = this.runHistory[this.runHistory.length - 1];
340
+ if (mainRecord)
341
+ mainRecord.chainGroupId = chainGroupId;
342
+ let currentTargetId = chainTargetId;
343
+ while (currentTargetId) {
344
+ const chainTask = this.taskResolver(currentTargetId);
345
+ if (!chainTask)
346
+ break;
344
347
  const chainStartedAt = new Date().toISOString();
345
348
  const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
346
349
  this.runHistory.push({
@@ -356,7 +359,7 @@ export class LoopController extends EventEmitter {
356
359
  });
357
360
  const chainResult = await executeCommand(chainTask.command, chainTask.commandArgs, this.options.cwd, this.logStream, signal, this.runCount);
358
361
  const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
359
- const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running");
362
+ const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
360
363
  if (chainRecord) {
361
364
  chainRecord.exitCode = chainResult.exitCode;
362
365
  chainRecord.duration = chainResult.duration;
@@ -365,6 +368,7 @@ export class LoopController extends EventEmitter {
365
368
  }
366
369
  this.lastExitCode = chainResult.exitCode;
367
370
  this.lastDuration = (this.lastDuration ?? 0) + chainResult.duration;
371
+ currentTargetId = (chainResult.exitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
368
372
  }
369
373
  }
370
374
  if (this.runHistory.length > 50) {
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { LoopController } from "../core/loop-controller.js";
3
3
  import { ProjectManager } from "./projects.js";
4
- import { saveLoop, loadAllLoops, deleteLoop as deleteLoopState, getLogPath, } from "./state.js";
4
+ import { saveLoop, loadAllLoops, deleteLoop as deleteLoopState, getLogPath, migrateLoopsToJson, migrateTasksToJson, } from "./state.js";
5
5
  import { daemonLog } from "./daemon-log.js";
6
6
  export class LoopManager {
7
7
  loops = new Map();
@@ -16,6 +16,9 @@ export class LoopManager {
16
16
  init() {
17
17
  // Initialize projects first
18
18
  this.projectManager.init();
19
+ // Migrate from individual files to consolidated JSON
20
+ migrateLoopsToJson();
21
+ migrateTasksToJson();
19
22
  const saved = loadAllLoops();
20
23
  let restarted = 0;
21
24
  let migrated = 0;
@@ -1,53 +1,76 @@
1
1
  import fs from "node:fs";
2
2
  import { writeFileAtomic } from "../shared/fs-utils.js";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
3
  import { daemonLog } from "./daemon-log.js";
6
4
  import crypto from "node:crypto";
5
+ import { getDataDir, projectsJson } from "../config/paths.js";
6
+ import path from "node:path";
7
7
  export class ProjectManager {
8
8
  projects = new Map();
9
- projectsDir;
10
- constructor(loopCliHome) {
11
- const baseDir = loopCliHome || join(homedir(), ".loop-cli");
12
- this.projectsDir = join(baseDir, "projects");
13
- }
14
9
  init() {
15
- if (!fs.existsSync(this.projectsDir)) {
16
- fs.mkdirSync(this.projectsDir, { recursive: true });
17
- }
10
+ const dataDir = getDataDir();
11
+ fs.mkdirSync(dataDir, { recursive: true });
12
+ this.migrateProjectsToJson();
18
13
  this.loadProjects();
19
14
  if (!this.projects.has("default")) {
20
15
  this.createDefaultProject();
21
16
  }
22
17
  }
23
- loadProjects() {
24
- this.projects.clear();
25
- if (!fs.existsSync(this.projectsDir))
18
+ migrateProjectsToJson() {
19
+ const jsonFile = projectsJson();
20
+ if (fs.existsSync(jsonFile))
21
+ return;
22
+ const oldDir = path.join(getDataDir(), "projects");
23
+ if (!fs.existsSync(oldDir))
26
24
  return;
27
25
  try {
28
- const files = fs.readdirSync(this.projectsDir);
26
+ const files = fs.readdirSync(oldDir).filter((f) => f.endsWith(".json"));
27
+ if (files.length === 0)
28
+ return;
29
+ const projects = [];
29
30
  for (const file of files) {
30
- if (!file.endsWith(".json"))
31
- continue;
32
31
  try {
33
- const raw = fs.readFileSync(join(this.projectsDir, file), "utf-8");
32
+ const raw = fs.readFileSync(path.join(oldDir, file), "utf-8");
34
33
  const content = JSON.parse(raw);
35
34
  if (content.id)
36
- this.projects.set(content.id, content);
35
+ projects.push(content);
37
36
  }
38
- catch (err) {
39
- daemonLog(`Failed to load project ${file}: ${err}`);
37
+ catch {
38
+ // skip corrupted files
40
39
  }
41
40
  }
41
+ writeFileAtomic(jsonFile, JSON.stringify(projects, null, 2));
42
+ daemonLog(`migrated ${projects.length} project(s) to projects.json`);
42
43
  }
43
44
  catch (err) {
44
- daemonLog(`Failed to load projects directory: ${err}`);
45
+ daemonLog(`Failed to migrate projects: ${err}`);
45
46
  }
46
47
  }
48
+ loadProjects() {
49
+ this.projects.clear();
50
+ const jsonFile = projectsJson();
51
+ if (!fs.existsSync(jsonFile))
52
+ return;
53
+ try {
54
+ const raw = fs.readFileSync(jsonFile, "utf-8");
55
+ const parsed = JSON.parse(raw);
56
+ if (!Array.isArray(parsed))
57
+ return;
58
+ for (const item of parsed) {
59
+ if (item && item.id)
60
+ this.projects.set(item.id, item);
61
+ }
62
+ }
63
+ catch (err) {
64
+ daemonLog(`Failed to load projects.json: ${err}`);
65
+ }
66
+ }
67
+ saveAllProjects() {
68
+ const all = Array.from(this.projects.values());
69
+ writeFileAtomic(projectsJson(), JSON.stringify(all, null, 2));
70
+ }
47
71
  saveProject(project) {
48
- const filePath = join(this.projectsDir, `${project.id}.json`);
49
- writeFileAtomic(filePath, JSON.stringify(project, null, 2));
50
72
  this.projects.set(project.id, project);
73
+ this.saveAllProjects();
51
74
  }
52
75
  createDefaultProject() {
53
76
  const defaultProject = {
@@ -96,9 +119,7 @@ export class ProjectManager {
96
119
  throw new Error(`Project ${id} not found`);
97
120
  if (project.isSystem)
98
121
  throw new Error("Cannot delete system project");
99
- const filePath = join(this.projectsDir, `${id}.json`);
100
- if (fs.existsSync(filePath))
101
- fs.rmSync(filePath);
102
122
  this.projects.delete(id);
123
+ this.saveAllProjects();
103
124
  }
104
125
  }
@@ -3,27 +3,39 @@ import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { removeIfExists, writeFileAtomic } from "../shared/fs-utils.js";
6
- import { getLoopsDir, getTasksDir, getLogsDir, loopFile, taskFile, logFile, getPidFile, getSignatureFile, getSocketPath, } from "../config/paths.js";
6
+ import { getLoopsDir, getTasksDir, getLogsDir, logFile, getPidFile, getSignatureFile, getSocketPath, loopsJson, tasksJson, getDataDir, } from "../config/paths.js";
7
7
  export { getDataDir, getPidFile, getSocketPath } from "../config/paths.js";
8
8
  function ensureDirs() {
9
- fs.mkdirSync(getLoopsDir(), { recursive: true });
9
+ fs.mkdirSync(getDataDir(), { recursive: true });
10
10
  fs.mkdirSync(getLogsDir(), { recursive: true });
11
11
  }
12
- export function saveLoop(meta) {
13
- ensureDirs();
14
- writeFileAtomic(loopFile(meta.id), JSON.stringify(meta, null, 2));
12
+ function readJsonArray(filePath) {
13
+ if (!fs.existsSync(filePath))
14
+ return [];
15
+ try {
16
+ const raw = fs.readFileSync(filePath, "utf-8");
17
+ const parsed = JSON.parse(raw);
18
+ return Array.isArray(parsed) ? parsed : [];
19
+ }
20
+ catch {
21
+ return [];
22
+ }
15
23
  }
16
- export function loadLoop(id) {
17
- const file = loopFile(id);
18
- if (!fs.existsSync(file))
19
- return null;
20
- const raw = fs.readFileSync(file, "utf-8");
21
- return JSON.parse(raw);
24
+ function writeJsonArray(filePath, items) {
25
+ ensureDirs();
26
+ writeFileAtomic(filePath, JSON.stringify(items, null, 2));
22
27
  }
23
- export function loadAllLoops() {
28
+ export function migrateLoopsToJson() {
24
29
  ensureDirs();
30
+ const jsonFile = loopsJson();
31
+ if (fs.existsSync(jsonFile))
32
+ return;
25
33
  const dir = getLoopsDir();
34
+ if (!fs.existsSync(dir))
35
+ return;
26
36
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
37
+ if (files.length === 0)
38
+ return;
27
39
  const loops = [];
28
40
  for (const file of files) {
29
41
  try {
@@ -34,28 +46,20 @@ export function loadAllLoops() {
34
46
  // skip corrupted files
35
47
  }
36
48
  }
37
- return loops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
38
- }
39
- export function deleteLoop(id) {
40
- removeIfExists(loopFile(id));
41
- removeIfExists(logFile(id));
42
- }
43
- export function saveTask(task) {
44
- const dir = getTasksDir();
45
- fs.mkdirSync(dir, { recursive: true });
46
- writeFileAtomic(taskFile(task.id), JSON.stringify(task, null, 2));
47
- }
48
- export function loadTask(id) {
49
- const file = taskFile(id);
50
- if (!fs.existsSync(file))
51
- return null;
52
- const raw = fs.readFileSync(file, "utf-8");
53
- return JSON.parse(raw);
49
+ loops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
50
+ writeJsonArray(jsonFile, loops);
54
51
  }
55
- export function loadAllTasks() {
52
+ export function migrateTasksToJson() {
53
+ ensureDirs();
54
+ const jsonFile = tasksJson();
55
+ if (fs.existsSync(jsonFile))
56
+ return;
56
57
  const dir = getTasksDir();
57
- fs.mkdirSync(dir, { recursive: true });
58
+ if (!fs.existsSync(dir))
59
+ return;
58
60
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
61
+ if (files.length === 0)
62
+ return;
59
63
  const tasks = [];
60
64
  for (const file of files) {
61
65
  try {
@@ -66,10 +70,57 @@ export function loadAllTasks() {
66
70
  // skip corrupted files
67
71
  }
68
72
  }
73
+ tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
74
+ writeJsonArray(jsonFile, tasks);
75
+ }
76
+ export function saveLoop(meta) {
77
+ const loops = readJsonArray(loopsJson());
78
+ const idx = loops.findIndex((l) => l.id === meta.id);
79
+ if (idx >= 0)
80
+ loops[idx] = meta;
81
+ else
82
+ loops.push(meta);
83
+ writeJsonArray(loopsJson(), loops);
84
+ }
85
+ export function loadLoop(id) {
86
+ const loops = readJsonArray(loopsJson());
87
+ return loops.find((l) => l.id === id) ?? null;
88
+ }
89
+ export function loadAllLoops() {
90
+ const loops = readJsonArray(loopsJson());
91
+ return loops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
92
+ }
93
+ export function deleteLoop(id) {
94
+ const loops = readJsonArray(loopsJson());
95
+ const filtered = loops.filter((l) => l.id !== id);
96
+ if (filtered.length !== loops.length) {
97
+ writeJsonArray(loopsJson(), filtered);
98
+ }
99
+ removeIfExists(logFile(id));
100
+ }
101
+ export function saveTask(task) {
102
+ const tasks = readJsonArray(tasksJson());
103
+ const idx = tasks.findIndex((t) => t.id === task.id);
104
+ if (idx >= 0)
105
+ tasks[idx] = task;
106
+ else
107
+ tasks.push(task);
108
+ writeJsonArray(tasksJson(), tasks);
109
+ }
110
+ export function loadTask(id) {
111
+ const tasks = readJsonArray(tasksJson());
112
+ return tasks.find((t) => t.id === id) ?? null;
113
+ }
114
+ export function loadAllTasks() {
115
+ const tasks = readJsonArray(tasksJson());
69
116
  return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
70
117
  }
71
118
  export function deleteTask(id) {
72
- removeIfExists(taskFile(id));
119
+ const tasks = readJsonArray(tasksJson());
120
+ const filtered = tasks.filter((t) => t.id !== id);
121
+ if (filtered.length !== tasks.length) {
122
+ writeJsonArray(tasksJson(), filtered);
123
+ }
73
124
  }
74
125
  export function getLogPath(id) {
75
126
  ensureDirs();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
5
5
  "type": "module",
6
6
  "bin": {