loop-task 1.5.5 → 2.0.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.
Files changed (105) hide show
  1. package/README.md +141 -21
  2. package/dist/board/App.js +1 -1
  3. package/dist/board/components/ActionButtons.js +1 -1
  4. package/dist/board/components/BoardButton.js +1 -1
  5. package/dist/board/components/ConfirmModal.js +1 -1
  6. package/dist/board/components/ContextHelpModal.js +1 -1
  7. package/dist/board/components/CreateForm.js +1 -1
  8. package/dist/board/components/CreateProjectModal.js +1 -1
  9. package/dist/board/components/DeleteProjectConfirm.js +1 -1
  10. package/dist/board/components/DetailView.js +1 -1
  11. package/dist/board/components/EditProjectModal.js +1 -1
  12. package/dist/board/components/FilterBar.js +1 -1
  13. package/dist/board/components/Footer.js +1 -1
  14. package/dist/board/components/Header.js +1 -1
  15. package/dist/board/components/HelpModal.js +1 -1
  16. package/dist/board/components/Inspector.js +1 -1
  17. package/dist/board/components/LogModal.js +1 -1
  18. package/dist/board/components/Navigator.js +1 -1
  19. package/dist/board/components/ProjectsModal.js +1 -1
  20. package/dist/board/components/ProjectsPage.js +1 -1
  21. package/dist/board/components/RunHistory.js +1 -1
  22. package/dist/board/components/SearchBox.js +1 -1
  23. package/dist/board/components/SearchSelect.js +1 -1
  24. package/dist/board/components/TaskBrowser.js +1 -1
  25. package/dist/board/components/TaskFilterBar.js +1 -1
  26. package/dist/board/components/TaskForm.js +1 -1
  27. package/dist/board/components/Timeline.js +1 -1
  28. package/dist/board/focus-context.js +1 -1
  29. package/dist/board/format.js +15 -6
  30. package/dist/board/index.js +1 -1
  31. package/dist/board/toast.js +1 -1
  32. package/dist/cli/import-validator.js +151 -0
  33. package/dist/cli/import-writer.js +77 -0
  34. package/dist/cli.js +111 -17
  35. package/dist/config/constants.js +52 -0
  36. package/dist/daemon/file-watcher.js +171 -0
  37. package/dist/daemon/http-server.js +605 -0
  38. package/dist/daemon/index.js +36 -6
  39. package/dist/daemon/manager.js +94 -13
  40. package/dist/daemon/projects.js +10 -0
  41. package/dist/daemon/server.js +21 -0
  42. package/dist/daemon/task-manager.js +7 -0
  43. package/dist/esm-loader.js +4 -1
  44. package/dist/hooks/useLoopFormValidation.js +140 -0
  45. package/dist/i18n/en.json +237 -17
  46. package/dist/shared/fs-utils.js +34 -1
  47. package/dist/tui/app.js +675 -151
  48. package/dist/tui/commands.js +114 -0
  49. package/dist/tui/components/ActionButtons.js +49 -0
  50. package/dist/tui/components/Button.js +18 -0
  51. package/dist/tui/components/ChainEditor.js +112 -0
  52. package/dist/tui/components/CommandBuilderField.js +143 -0
  53. package/dist/tui/components/CommandEditorModal.js +159 -0
  54. package/dist/tui/components/CommandInput.js +303 -0
  55. package/dist/tui/components/CommandsBrowserModal.js +77 -0
  56. package/dist/tui/components/ConfirmModal.js +9 -0
  57. package/dist/tui/components/ContextHelpModal.js +8 -0
  58. package/dist/tui/components/CreateForm.js +223 -0
  59. package/dist/tui/components/DebugPanel.js +8 -0
  60. package/dist/tui/components/ExportModal.js +37 -0
  61. package/dist/tui/components/FilterBar.js +13 -0
  62. package/dist/tui/components/FocusableButton.js +16 -0
  63. package/dist/tui/components/FocusableInput.js +69 -0
  64. package/dist/tui/components/FocusableList.js +46 -0
  65. package/dist/tui/components/FocusableSearchSelect.js +82 -0
  66. package/dist/tui/components/Footer.js +42 -0
  67. package/dist/tui/components/Header.js +40 -0
  68. package/dist/tui/components/HelpGuideModal.js +21 -0
  69. package/dist/tui/components/HelpModal.js +59 -0
  70. package/dist/tui/components/InlineCommandEditor.js +143 -0
  71. package/dist/tui/components/Inspector.js +31 -0
  72. package/dist/tui/components/LeftPanel.js +33 -0
  73. package/dist/tui/components/LogModal.js +121 -0
  74. package/dist/tui/components/Modal.js +11 -0
  75. package/dist/tui/components/Navigator.js +68 -0
  76. package/dist/tui/components/PatchEditForm.js +78 -0
  77. package/dist/tui/components/ProjectForm.js +64 -0
  78. package/dist/tui/components/ProjectsModal.js +40 -0
  79. package/dist/tui/components/ProjectsPage.js +99 -0
  80. package/dist/tui/components/RightPanel.js +34 -0
  81. package/dist/tui/components/RunHistory.js +108 -0
  82. package/dist/tui/components/SearchSelect.js +67 -0
  83. package/dist/tui/components/SelectModal.js +76 -0
  84. package/dist/tui/components/TabBar.js +20 -0
  85. package/dist/tui/components/TaskBrowser.js +96 -0
  86. package/dist/tui/components/TaskFilterBar.js +9 -0
  87. package/dist/tui/components/TaskForm.js +134 -0
  88. package/dist/tui/components/TaskPickerModal.js +60 -0
  89. package/dist/tui/components/Toast.js +37 -0
  90. package/dist/tui/components/WelcomeScreen.js +70 -0
  91. package/dist/tui/components/WizardForm.js +178 -0
  92. package/dist/tui/daemon.js +142 -0
  93. package/dist/tui/format.js +111 -0
  94. package/dist/tui/hooks/useBreakpoint.js +7 -0
  95. package/dist/tui/hooks/useHoverState.js +11 -0
  96. package/dist/tui/hooks/useLogStream.js +32 -0
  97. package/dist/tui/hooks/useLoopPolling.js +28 -0
  98. package/dist/tui/index.js +24 -0
  99. package/dist/tui/router.js +16 -0
  100. package/dist/tui/state.js +175 -0
  101. package/dist/tui/theme.js +96 -0
  102. package/dist/tui/types.js +1 -0
  103. package/dist/tui/utils/paste.js +10 -0
  104. package/dist/tui/utils/validation.js +56 -0
  105. package/package.json +13 -8
@@ -0,0 +1,151 @@
1
+ const PROJECT_FIELDS = [
2
+ { name: "id", type: "string", nullable: false },
3
+ { name: "name", type: "string", nullable: false },
4
+ { name: "color", type: "string", nullable: false },
5
+ { name: "createdAt", type: "string", nullable: false },
6
+ { name: "isSystem", type: "boolean", nullable: false },
7
+ { name: "isDefault", type: "boolean", nullable: false },
8
+ ];
9
+ const TASK_FIELDS = [
10
+ { name: "id", type: "string", nullable: false },
11
+ { name: "name", type: "string", nullable: false },
12
+ { name: "command", type: "string", nullable: false },
13
+ { name: "commandArgs", type: "array", nullable: false },
14
+ { name: "onSuccessTaskId", type: "string", nullable: true },
15
+ { name: "onFailureTaskId", type: "string", nullable: true },
16
+ { name: "createdAt", type: "string", nullable: false },
17
+ ];
18
+ const LOOP_FIELDS = [
19
+ { name: "id", type: "string", nullable: false },
20
+ { name: "taskId", type: "string", nullable: true },
21
+ { name: "command", type: "string", nullable: false },
22
+ { name: "commandArgs", type: "array", nullable: false },
23
+ { name: "interval", type: "number", nullable: false },
24
+ { name: "intervalHuman", type: "string", nullable: false },
25
+ { name: "immediate", type: "boolean", nullable: false },
26
+ { name: "maxRuns", type: "number", nullable: true },
27
+ { name: "verbose", type: "boolean", nullable: false },
28
+ { name: "cwd", type: "string", nullable: false },
29
+ { name: "description", type: "string", nullable: false },
30
+ { name: "status", type: "string", nullable: false },
31
+ { name: "createdAt", type: "string", nullable: false },
32
+ { name: "sessionStartedAt", type: "string", nullable: true },
33
+ { name: "runCount", type: "number", nullable: false },
34
+ { name: "lastRunAt", type: "string", nullable: true },
35
+ { name: "lastExitCode", type: "number", nullable: true },
36
+ { name: "lastDuration", type: "number", nullable: true },
37
+ { name: "nextRunAt", type: "string", nullable: true },
38
+ { name: "remainingDelayMs", type: "number", nullable: true },
39
+ { name: "pid", type: "number", nullable: false },
40
+ { name: "maxRunsReached", type: "boolean", nullable: false },
41
+ { name: "runHistory", type: "array", nullable: false },
42
+ { name: "skippedCount", type: "number", nullable: false },
43
+ { name: "projectId", type: "string", nullable: false },
44
+ { name: "offset", type: "number", nullable: true },
45
+ ];
46
+ const EXPECTED_VERSION = 2;
47
+ const REQUIRED_KEYS = ["loops", "tasks", "projects"];
48
+ function validateItems(items, fields, label) {
49
+ const errors = [];
50
+ for (let i = 0; i < items.length; i++) {
51
+ const item = items[i];
52
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
53
+ errors.push({ key: "item", message: `${label}[${i}]: must be an object` });
54
+ continue;
55
+ }
56
+ const record = item;
57
+ for (const field of fields) {
58
+ const value = record[field.name];
59
+ if (value === undefined || value === null) {
60
+ if (!field.nullable) {
61
+ errors.push({ key: "item", message: `${label}[${i}]: missing field '${field.name}'` });
62
+ }
63
+ continue;
64
+ }
65
+ if (field.nullable && value === null)
66
+ continue;
67
+ switch (field.type) {
68
+ case "string":
69
+ if (typeof value !== "string") {
70
+ errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be a string` });
71
+ }
72
+ break;
73
+ case "number":
74
+ if (typeof value !== "number") {
75
+ errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be a number` });
76
+ }
77
+ break;
78
+ case "boolean":
79
+ if (typeof value !== "boolean") {
80
+ errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be a boolean` });
81
+ }
82
+ break;
83
+ case "array":
84
+ if (!Array.isArray(value)) {
85
+ errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be an array` });
86
+ }
87
+ break;
88
+ case "object":
89
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
90
+ errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be an object` });
91
+ }
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ return errors;
97
+ }
98
+ export function validateExportFile(raw) {
99
+ const errors = [];
100
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
101
+ return { valid: false, errors: [{ key: "version", message: "Invalid export file: expected a JSON object" }] };
102
+ }
103
+ const data = raw;
104
+ if (!("version" in data)) {
105
+ return { valid: false, errors: [{ key: "version", message: "Missing export version. Expected: 2" }] };
106
+ }
107
+ if (data.version !== EXPECTED_VERSION) {
108
+ return {
109
+ valid: false,
110
+ errors: [{ key: "version", message: `Unsupported export version: ${data.version}. Expected: ${EXPECTED_VERSION}` }],
111
+ };
112
+ }
113
+ const missingKeys = [];
114
+ const nonArrayKeys = [];
115
+ for (const key of REQUIRED_KEYS) {
116
+ if (!(key in data)) {
117
+ missingKeys.push(key);
118
+ }
119
+ else if (!Array.isArray(data[key])) {
120
+ nonArrayKeys.push(key);
121
+ }
122
+ }
123
+ if (missingKeys.length > 0) {
124
+ errors.push({ key: "missing_keys", message: `Missing required keys: ${missingKeys.join(", ")}` });
125
+ }
126
+ if (nonArrayKeys.length > 0) {
127
+ for (const key of nonArrayKeys) {
128
+ errors.push({ key: "non_array_keys", message: `field '${key}' must be an array` });
129
+ }
130
+ }
131
+ if (errors.length > 0) {
132
+ return { valid: false, errors };
133
+ }
134
+ const itemErrors = [
135
+ ...validateItems(data.loops, LOOP_FIELDS, "loops"),
136
+ ...validateItems(data.tasks, TASK_FIELDS, "tasks"),
137
+ ...validateItems(data.projects, PROJECT_FIELDS, "projects"),
138
+ ];
139
+ if (itemErrors.length > 0) {
140
+ return { valid: false, errors: itemErrors };
141
+ }
142
+ return {
143
+ valid: true,
144
+ errors: [],
145
+ data: {
146
+ loops: data.loops,
147
+ tasks: data.tasks,
148
+ projects: data.projects,
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { writeFileAtomic } from "../shared/fs-utils.js";
4
+ import { getDataDir } from "../config/paths.js";
5
+ const STORE_FILES = ["loops.json", "tasks.json", "projects.json"];
6
+ function backupFilePath(filePath) {
7
+ return `${filePath}.bak`;
8
+ }
9
+ export function atomicImportWrite(loops, tasks, projects) {
10
+ const dataDir = getDataDir();
11
+ fs.mkdirSync(dataDir, { recursive: true });
12
+ const contents = {
13
+ "loops.json": JSON.stringify(loops, null, 2),
14
+ "tasks.json": JSON.stringify(tasks, null, 2),
15
+ "projects.json": JSON.stringify(projects, null, 2),
16
+ };
17
+ const backups = new Map();
18
+ for (const store of STORE_FILES) {
19
+ const storePath = path.join(dataDir, store);
20
+ if (fs.existsSync(storePath) && fs.statSync(storePath).isFile()) {
21
+ backups.set(store, fs.readFileSync(storePath, "utf-8"));
22
+ }
23
+ else {
24
+ backups.set(store, null);
25
+ }
26
+ }
27
+ const writtenStores = [];
28
+ try {
29
+ for (const store of STORE_FILES) {
30
+ const storePath = path.join(dataDir, store);
31
+ writeFileAtomic(storePath, contents[store]);
32
+ writtenStores.push(store);
33
+ }
34
+ }
35
+ catch (err) {
36
+ const writeError = err;
37
+ for (const store of STORE_FILES) {
38
+ const storePath = path.join(dataDir, store);
39
+ const backup = backups.get(store) ?? null;
40
+ if (backup !== null) {
41
+ try {
42
+ writeFileAtomic(storePath, backup);
43
+ }
44
+ catch {
45
+ // best-effort restore
46
+ }
47
+ }
48
+ else {
49
+ try {
50
+ if (fs.existsSync(storePath) && fs.statSync(storePath).isFile()) {
51
+ fs.unlinkSync(storePath);
52
+ }
53
+ }
54
+ catch {
55
+ // best-effort cleanup
56
+ }
57
+ }
58
+ }
59
+ return {
60
+ success: false,
61
+ error: writeError.message,
62
+ writtenStores,
63
+ };
64
+ }
65
+ for (const store of STORE_FILES) {
66
+ const bakPath = backupFilePath(path.join(dataDir, store));
67
+ try {
68
+ if (fs.existsSync(bakPath)) {
69
+ fs.unlinkSync(bakPath);
70
+ }
71
+ }
72
+ catch {
73
+ // best-effort cleanup
74
+ }
75
+ }
76
+ return { success: true, writtenStores: [...STORE_FILES] };
77
+ }
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { parseDuration } from "./duration.js";
8
8
  import { startLoop } from "./client/commands.js";
9
9
  import { listProjectsCli, createProjectCli, renameProjectCli, setProjectColorCli, deleteProjectCli, resolveProjectId, } from "./client/commands.js";
10
10
  import { t } from "./i18n/index.js";
11
+ import { HTTP_API_PORT, HTTP_API_HOST } from "./config/constants.js";
11
12
  const require = createRequire(import.meta.url);
12
13
  const packageJson = require("../package.json");
13
14
  const program = new Command();
@@ -77,6 +78,7 @@ program
77
78
  .option("--cwd <dir>", t("cli.optCwd"))
78
79
  .option("--project <name>", t("cli.optProject"))
79
80
  .option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
81
+ .option("--description <desc>", "Description of the loop")
80
82
  .action(async (intervalStr, cmdArgs, opts) => {
81
83
  try {
82
84
  const projectId = opts.project
@@ -90,6 +92,7 @@ program
90
92
  cwd: opts.cwd ?? process.cwd(),
91
93
  projectId,
92
94
  offset: offsetMs,
95
+ description: opts.description ?? cmdArgs.join(" "),
93
96
  });
94
97
  await startLoop(built.options, built.intervalHuman);
95
98
  }
@@ -108,6 +111,7 @@ program
108
111
  .option("--max-runs <n>", t("cli.optMaxRuns"))
109
112
  .option("--verbose", t("cli.optVerbose"))
110
113
  .option("--cwd <dir>", t("cli.optCwd"))
114
+ .option("--description <desc>", "Description of the loop")
111
115
  .action(async (intervalStr, cmdArgs, opts) => {
112
116
  if (!intervalStr || !cmdArgs || cmdArgs.length === 0) {
113
117
  program.help();
@@ -119,6 +123,7 @@ program
119
123
  command: cmdArgs[0],
120
124
  commandArgs: cmdArgs.slice(1),
121
125
  cwd: opts.cwd ?? process.cwd(),
126
+ description: opts.description ?? cmdArgs.join(" "),
122
127
  });
123
128
  const controller = new AbortController();
124
129
  process.on("SIGINT", () => controller.abort());
@@ -165,23 +170,112 @@ projectCmd
165
170
  await deleteProjectCli(idOrName);
166
171
  });
167
172
  program.action(async () => {
168
- if (!process.execPath.includes("bun")) {
169
- const { spawn } = await import("node:child_process");
170
- const child = spawn("bun", [process.argv[1]], {
171
- stdio: "inherit",
172
- env: { ...process.env },
173
- shell: true,
174
- });
175
- child.on("error", () => {
176
- console.error("The board requires the Bun runtime for OpenTUI native FFI.\n" +
177
- "Install Bun: npm install -g bun\n" +
178
- "Then run: loop-task");
179
- process.exit(1);
180
- });
181
- child.on("exit", (code) => process.exit(code ?? 0));
182
- return;
183
- }
184
- const { launchBoard } = await import("./board/index.js");
173
+ const { launchBoard } = await import("./tui/index.js");
185
174
  await launchBoard();
186
175
  });
176
+ program
177
+ .command("status")
178
+ .description("Show status of all loops")
179
+ .option("--json", "Output as JSON")
180
+ .action(async (opts) => {
181
+ const { sendRequest } = await import("./client/ipc.js");
182
+ const response = await sendRequest({ type: "list" });
183
+ if (response.type !== "ok") {
184
+ console.error("Failed to get status");
185
+ process.exit(1);
186
+ }
187
+ const loops = response.data;
188
+ if (opts.json) {
189
+ console.log(JSON.stringify(loops, null, 2));
190
+ }
191
+ else {
192
+ for (const loop of loops) {
193
+ const { describeLoop, statusLabel } = await import("./board/format.js");
194
+ console.log(`${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`);
195
+ }
196
+ }
197
+ });
198
+ program
199
+ .command("export")
200
+ .description("Export all configs to a JSON file (or stdout)")
201
+ .argument("[file]", "Output file (defaults to stdout)")
202
+ .action(async (file) => {
203
+ const { sendRequest } = await import("./client/ipc.js");
204
+ const [loopsRes, tasksRes, projectsRes] = await Promise.all([
205
+ sendRequest({ type: "list" }),
206
+ sendRequest({ type: "task-list" }),
207
+ sendRequest({ type: "project-list" }),
208
+ ]);
209
+ const exportData = {
210
+ version: 2,
211
+ exportedAt: new Date().toISOString(),
212
+ loops: loopsRes.type === "ok" ? loopsRes.data : [],
213
+ tasks: tasksRes.type === "ok" ? tasksRes.data : [],
214
+ projects: projectsRes.type === "ok" ? projectsRes.data : [],
215
+ };
216
+ const json = JSON.stringify(exportData, null, 2);
217
+ if (file) {
218
+ const { writeFile } = await import("node:fs/promises");
219
+ await writeFile(file, json, "utf-8");
220
+ console.log(`Exported to ${file}`);
221
+ }
222
+ else {
223
+ console.log(json);
224
+ }
225
+ });
226
+ program
227
+ .command("import")
228
+ .description("Restore a previously exported state file (inverse of loop-task export)")
229
+ .argument("<file>", "Input file")
230
+ .action(async (file) => {
231
+ const { readFile } = await import("node:fs/promises");
232
+ let content;
233
+ try {
234
+ content = await readFile(file, "utf-8");
235
+ }
236
+ catch (err) {
237
+ const e = err;
238
+ console.error(`Failed to read file: ${e.message}`);
239
+ process.exit(1);
240
+ }
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(content);
244
+ }
245
+ catch {
246
+ console.error("Invalid JSON in import file");
247
+ process.exit(1);
248
+ }
249
+ const { validateExportFile } = await import("./cli/import-validator.js");
250
+ const result = validateExportFile(parsed);
251
+ if (!result.valid) {
252
+ for (const error of result.errors) {
253
+ console.error(error.message);
254
+ }
255
+ process.exit(1);
256
+ }
257
+ const { atomicImportWrite } = await import("./cli/import-writer.js");
258
+ const writeResult = atomicImportWrite(result.data.loops, result.data.tasks, result.data.projects);
259
+ if (!writeResult.success) {
260
+ console.error(`Failed to write store: ${writeResult.error}`);
261
+ if (writeResult.writtenStores.length > 0) {
262
+ console.error("Rolled back previously written stores.");
263
+ }
264
+ process.exit(1);
265
+ }
266
+ console.log(`Imported ${result.data.loops.length} loops, ${result.data.tasks.length} tasks, ${result.data.projects.length} projects`);
267
+ console.log("Daemon will hot-reload automatically.");
268
+ });
269
+ program
270
+ .command("api")
271
+ .description("Show HTTP API server info (base URL, Swagger UI, OpenAPI spec)")
272
+ .action(() => {
273
+ const port = process.env.LOOP_CLI_HTTP_PORT ?? String(HTTP_API_PORT);
274
+ const baseUrl = `http://${HTTP_API_HOST}:${port}`;
275
+ console.log(`HTTP API Server`);
276
+ console.log(` Base URL: ${baseUrl}`);
277
+ console.log(` Swagger UI: ${baseUrl}/api/docs`);
278
+ console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
279
+ console.log(` Events: ${baseUrl}/api/events (SSE)`);
280
+ });
187
281
  await program.parseAsync(process.argv);
@@ -13,6 +13,12 @@ export const LOG_TAIL_DEFAULT = 50;
13
13
  export const BOARD_BREAKPOINT_WIDTH = 80;
14
14
  export const HEADER_COMPACT_WIDTH = 60;
15
15
  export const SEARCH_SELECT_HEIGHT = 6;
16
+ // ── Clipboard / bracketed paste ─────────────────────────────────────
17
+ // DECSET 2004: terminal wraps pasted text in ESC[200~ ... ESC[201~ so the
18
+ // app can insert it wholesale instead of processing each char as a keypress.
19
+ export const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
20
+ export const BRACKETED_PASTE_DISABLE = "\x1b[?2004l";
21
+ export const PASTE_MAX_CHARS = 4096;
16
22
  export const HOVER_BG = "#1e3a5f";
17
23
  export const PROJECT_COLORS = {
18
24
  white: "#ffffff",
@@ -29,3 +35,49 @@ export const ENTITY_COLORS = {
29
35
  task: "#a78bfa", // purple
30
36
  project: "#34d399", // green
31
37
  };
38
+ // ── Wizard step counts ──────────────────────────────────────────────
39
+ export const WIZARD_LOOP_REQUIRED_STEPS = 3;
40
+ export const WIZARD_LOOP_TOTAL_STEPS = 7;
41
+ export const WIZARD_TASK_REQUIRED_STEPS = 2;
42
+ export const WIZARD_TASK_TOTAL_STEPS = 4;
43
+ export const WIZARD_PROJECT_REQUIRED_STEPS = 1;
44
+ export const WIZARD_PROJECT_TOTAL_STEPS = 2;
45
+ // ── Command tiers ───────────────────────────────────────────────────
46
+ export const COMMAND_TIER_ACTION = "action";
47
+ export const COMMAND_TIER_CONFIRM = "confirm";
48
+ export const COMMAND_TIER_GLOBAL = "global";
49
+ // ── Command categories ─────────────────────────────────────────────
50
+ export const COMMAND_CATEGORY_GLOBAL = "global";
51
+ export const COMMAND_CATEGORY_FILTERS = "filters";
52
+ export const COMMAND_CATEGORY_LOOP = "loop";
53
+ export const COMMAND_CATEGORY_TASK = "task";
54
+ export const COMMAND_CATEGORY_PROJECT = "project";
55
+ // ── Confirm keywords ────────────────────────────────────────────────
56
+ export const CONFIRM_YES = "yes";
57
+ export const CONFIRM_CANCEL = "cancel";
58
+ // ── Panel focus types ───────────────────────────────────────────────
59
+ export const PANEL_LEFT = "left";
60
+ export const PANEL_RIGHT = "right";
61
+ // ── Command builder templates ──────────────────────────────────────
62
+ export const COMMAND_TEMPLATES = [
63
+ { label: "npm run", command: "npm", args: "run" },
64
+ { label: "npm test", command: "npm", args: "test" },
65
+ { label: "pnpm build", command: "pnpm", args: "build" },
66
+ { label: "pnpm test", command: "pnpm", args: "test" },
67
+ { label: "yarn build", command: "yarn", args: "build" },
68
+ { label: "dotnet build", command: "dotnet", args: "build" },
69
+ { label: "dotnet test", command: "dotnet", args: "test" },
70
+ { label: "docker compose up", command: "docker", args: "compose up" },
71
+ { label: "make", command: "make", args: "" },
72
+ { label: "shell script", command: "bash", args: "./script.sh" },
73
+ ];
74
+ // ── Command input constants ─────────────────────────────────────────
75
+ export const COMMAND_INPUT_HEIGHT = 6;
76
+ export const COMMAND_INPUT_DROPDOWN_MAX_VISIBLE = 6;
77
+ // ── HTTP API server ─────────────────────────────────────────────────
78
+ export const HTTP_API_PORT = 8845;
79
+ export const HTTP_API_HOST = "127.0.0.1";
80
+ // ── Ctrl shortcut hints (for display) ───────────────────────────────
81
+ export const EXPORT_MAX_PREVIEW_LINES = 200;
82
+ export const CTRL_SHORTCUT_EDIT = "Ctrl+E";
83
+ export const CTRL_SHORTCUT_DELETE = "Ctrl+D";
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { loopsJson, tasksJson, projectsJson } from "../config/paths.js";
5
+ const DEBOUNCE_MS = 300;
6
+ const MTIME_POLL_MS = 2000;
7
+ export class FileWatcher {
8
+ watchers = new Map();
9
+ mtimeTimers = new Map();
10
+ loopManager = null;
11
+ taskManager = null;
12
+ projectManager = null;
13
+ setManagers(loopManager, taskManager, projectManager) {
14
+ this.loopManager = loopManager;
15
+ this.taskManager = taskManager;
16
+ this.projectManager = projectManager;
17
+ }
18
+ start() {
19
+ this.watch(loopsJson(), (content) => this.handleLoopsChange(content));
20
+ this.watch(tasksJson(), (content) => this.handleTasksChange(content));
21
+ this.watch(projectsJson(), (content) => this.handleProjectsChange(content));
22
+ }
23
+ stop() {
24
+ for (const [, entry] of this.watchers) {
25
+ if (entry.debounceTimer)
26
+ clearTimeout(entry.debounceTimer);
27
+ }
28
+ for (const [, timer] of this.mtimeTimers) {
29
+ clearInterval(timer);
30
+ }
31
+ this.watchers.clear();
32
+ this.mtimeTimers.clear();
33
+ }
34
+ registerSelfWrite(filePath, content) {
35
+ const hash = this.hash(content);
36
+ const entry = this.watchers.get(filePath);
37
+ if (entry) {
38
+ entry.lastHash = hash;
39
+ }
40
+ }
41
+ watch(filePath, onChange) {
42
+ const dir = path.dirname(filePath);
43
+ const basename = path.basename(filePath);
44
+ this.watchers.set(filePath, {
45
+ lastHash: "",
46
+ debounceTimer: null,
47
+ lastMtime: 0,
48
+ });
49
+ try {
50
+ const initialContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
51
+ this.watchers.get(filePath).lastHash = this.hash(initialContent);
52
+ this.watchers.get(filePath).lastMtime = fs.statSync(filePath).mtimeMs;
53
+ }
54
+ catch {
55
+ // file doesn't exist yet
56
+ }
57
+ try {
58
+ fs.watch(dir, (eventType, filename) => {
59
+ if (filename !== basename)
60
+ return;
61
+ this.handleFileEvent(filePath, onChange);
62
+ });
63
+ }
64
+ catch {
65
+ // fs.watch not available, mtime polling will handle it
66
+ }
67
+ const mtimeTimer = setInterval(() => {
68
+ this.checkMtime(filePath, onChange);
69
+ }, MTIME_POLL_MS);
70
+ this.mtimeTimers.set(filePath, mtimeTimer);
71
+ }
72
+ handleFileEvent(filePath, onChange) {
73
+ const entry = this.watchers.get(filePath);
74
+ if (!entry)
75
+ return;
76
+ if (entry.debounceTimer) {
77
+ clearTimeout(entry.debounceTimer);
78
+ }
79
+ entry.debounceTimer = setTimeout(() => {
80
+ this.processChange(filePath, onChange);
81
+ entry.debounceTimer = null;
82
+ }, DEBOUNCE_MS);
83
+ }
84
+ checkMtime(filePath, onChange) {
85
+ const entry = this.watchers.get(filePath);
86
+ if (!entry)
87
+ return;
88
+ try {
89
+ const stat = fs.statSync(filePath);
90
+ if (stat.mtimeMs > entry.lastMtime) {
91
+ entry.lastMtime = stat.mtimeMs;
92
+ this.handleFileEvent(filePath, onChange);
93
+ }
94
+ }
95
+ catch {
96
+ // file might not exist
97
+ }
98
+ }
99
+ processChange(filePath, onChange) {
100
+ const entry = this.watchers.get(filePath);
101
+ if (!entry)
102
+ return;
103
+ let content;
104
+ try {
105
+ content = fs.readFileSync(filePath, "utf-8");
106
+ }
107
+ catch {
108
+ return;
109
+ }
110
+ const fileHash = this.hash(content);
111
+ if (fileHash === entry.lastHash) {
112
+ return;
113
+ }
114
+ entry.lastHash = fileHash;
115
+ try {
116
+ const stat = fs.statSync(filePath);
117
+ entry.lastMtime = stat.mtimeMs;
118
+ }
119
+ catch {
120
+ // ignore
121
+ }
122
+ try {
123
+ onChange(content);
124
+ }
125
+ catch (error) {
126
+ console.error(`[file-watcher] Error processing change to ${filePath}:`, error);
127
+ }
128
+ }
129
+ hash(content) {
130
+ return createHash("sha1").update(content).digest("hex").slice(0, 16);
131
+ }
132
+ handleLoopsChange(content) {
133
+ if (!this.loopManager)
134
+ return;
135
+ let newLoops;
136
+ try {
137
+ newLoops = JSON.parse(content);
138
+ }
139
+ catch {
140
+ console.error("[file-watcher] Malformed loops.json, keeping old state");
141
+ return;
142
+ }
143
+ this.loopManager.reconcile(newLoops);
144
+ }
145
+ handleTasksChange(content) {
146
+ if (!this.taskManager)
147
+ return;
148
+ let newTasks;
149
+ try {
150
+ newTasks = JSON.parse(content);
151
+ }
152
+ catch {
153
+ console.error("[file-watcher] Malformed tasks.json, keeping old state");
154
+ return;
155
+ }
156
+ this.taskManager.reload(newTasks);
157
+ }
158
+ handleProjectsChange(content) {
159
+ if (!this.projectManager)
160
+ return;
161
+ let newProjects;
162
+ try {
163
+ newProjects = JSON.parse(content);
164
+ }
165
+ catch {
166
+ console.error("[file-watcher] Malformed projects.json, keeping old state");
167
+ return;
168
+ }
169
+ this.projectManager.reload(newProjects);
170
+ }
171
+ }