loop-task 2.0.13 → 2.0.14

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.
@@ -9,6 +9,7 @@ import { computePhase, alignToPhase } from "./scheduling.js";
9
9
  import { t } from "../i18n/index.js";
10
10
  import { parseStdout } from "./context-parser.js";
11
11
  import { interpolate } from "./template.js";
12
+ import { resolveEffectiveCwd } from "./resolve-cwd.js";
12
13
  export class LoopController extends EventEmitter {
13
14
  abortController;
14
15
  runAbortController = null;
@@ -30,6 +31,7 @@ export class LoopController extends EventEmitter {
30
31
  options;
31
32
  logPath;
32
33
  taskResolver;
34
+ projectDirectory;
33
35
  remainingDelayMs = null;
34
36
  logStream = null;
35
37
  loopPromise = null;
@@ -37,12 +39,13 @@ export class LoopController extends EventEmitter {
37
39
  runHistory = [];
38
40
  currentRunStartOffset = 0;
39
41
  skippedCount = 0;
40
- constructor(id, options, logPath, taskResolver, state) {
42
+ constructor(id, options, logPath, taskResolver, state, projectDirectory) {
41
43
  super();
42
44
  this.id = id;
43
45
  this.options = options;
44
46
  this.logPath = logPath;
45
47
  this.taskResolver = taskResolver;
48
+ this.projectDirectory = projectDirectory;
46
49
  this.abortController = new AbortController();
47
50
  this.createdAt = state?.createdAt ?? new Date().toISOString();
48
51
  this.runCount = state?.runCount ?? 0;
@@ -91,6 +94,11 @@ export class LoopController extends EventEmitter {
91
94
  if (interruptCurrentRun) {
92
95
  this.runAbortController?.abort();
93
96
  }
97
+ this.abortController.abort();
98
+ if (this.resumeResolve) {
99
+ this.resumeResolve();
100
+ this.resumeResolve = null;
101
+ }
94
102
  this.emit("stopped");
95
103
  }
96
104
  }
@@ -324,7 +332,7 @@ export class LoopController extends EventEmitter {
324
332
  const task = this.options.taskId ? this.taskResolver(this.options.taskId) : null;
325
333
  const command = task?.command ?? this.options.command;
326
334
  const commandArgs = task?.commandArgs ?? this.options.commandArgs;
327
- const cwd = this.options.cwd;
335
+ const cwd = resolveEffectiveCwd(this.options.cwd, this.projectDirectory);
328
336
  const chainContext = {};
329
337
  const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
330
338
  const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, hasChainTasks);
@@ -387,7 +395,7 @@ export class LoopController extends EventEmitter {
387
395
  });
388
396
  const interpolatedCommand = interpolate(chainTask.command, chainContext);
389
397
  const interpolatedArgs = chainTask.commandArgs.map(a => interpolate(a, chainContext));
390
- const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, this.options.cwd, this.logStream, signal, this.runCount, true, true);
398
+ const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, cwd, this.logStream, signal, this.runCount, true, true);
391
399
  if (chainResult.stdout) {
392
400
  const parsed = parseStdout(chainResult.stdout);
393
401
  if (parsed !== null) {
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ export function resolveEffectiveCwd(loopCwd, projectDirectory) {
3
+ if (!loopCwd)
4
+ return projectDirectory || process.cwd();
5
+ if (path.isAbsolute(loopCwd))
6
+ return loopCwd;
7
+ return path.join(projectDirectory || process.cwd(), loopCwd);
8
+ }
@@ -13,6 +13,9 @@ export class LoopManager {
13
13
  this.projectManager = projectManager || new ProjectManager();
14
14
  }
15
15
  taskResolver = (taskId) => this.taskManager.get(taskId);
16
+ getProjectDirectory(projectId) {
17
+ return this.projectManager.get(projectId)?.directory;
18
+ }
16
19
  init() {
17
20
  // Initialize projects first
18
21
  this.projectManager.init();
@@ -39,6 +42,7 @@ export class LoopManager {
39
42
  offset: meta.offset ?? null,
40
43
  };
41
44
  const logPath = getLogPath(meta.id);
45
+ const projectDir = this.getProjectDirectory(options.projectId ?? "default");
42
46
  const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
43
47
  status: meta.status,
44
48
  createdAt: meta.createdAt,
@@ -50,7 +54,7 @@ export class LoopManager {
50
54
  nextRunAt: meta.nextRunAt,
51
55
  remainingDelayMs: meta.remainingDelayMs,
52
56
  runHistory: meta.runHistory,
53
- });
57
+ }, projectDir);
54
58
  this.loops.set(meta.id, {
55
59
  controller,
56
60
  options,
@@ -69,7 +73,8 @@ export class LoopManager {
69
73
  start(options, intervalHuman) {
70
74
  const id = crypto.randomUUID().slice(0, 8);
71
75
  const logPath = getLogPath(id);
72
- const controller = new LoopController(id, options, logPath, this.taskResolver);
76
+ const projectDir = this.getProjectDirectory(options.projectId ?? "default");
77
+ const controller = new LoopController(id, options, logPath, this.taskResolver, undefined, projectDir);
73
78
  this.loops.set(id, { controller, options, intervalHuman });
74
79
  controller.start();
75
80
  this.wireEvents(id, controller, options, intervalHuman);
@@ -112,11 +117,11 @@ export class LoopManager {
112
117
  listProjects() {
113
118
  return this.projectManager.getAll();
114
119
  }
115
- createProject(name, color) {
116
- return this.projectManager.create(name, color);
120
+ createProject(name, color, directory) {
121
+ return this.projectManager.create(name, color, directory);
117
122
  }
118
- updateProject(id, name, color) {
119
- this.projectManager.update(id, name, color);
123
+ updateProject(id, name, color, directory) {
124
+ this.projectManager.update(id, name, color, directory);
120
125
  }
121
126
  deleteProject(id) {
122
127
  for (const [loopId, entry] of this.loops) {
@@ -251,6 +256,7 @@ export class LoopManager {
251
256
  offset: meta.offset ?? null,
252
257
  };
253
258
  const logPath = getLogPath(meta.id);
259
+ const projectDir = this.getProjectDirectory(options.projectId ?? "default");
254
260
  const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
255
261
  status: meta.status,
256
262
  createdAt: meta.createdAt,
@@ -262,7 +268,7 @@ export class LoopManager {
262
268
  nextRunAt: meta.nextRunAt,
263
269
  remainingDelayMs: meta.remainingDelayMs,
264
270
  runHistory: meta.runHistory,
265
- });
271
+ }, projectDir);
266
272
  this.loops.set(meta.id, { controller, options, intervalHuman: meta.intervalHuman });
267
273
  this.wireEvents(meta.id, controller, options, meta.intervalHuman);
268
274
  if (shouldAutoStart(meta.status)) {
@@ -286,6 +292,7 @@ export class LoopManager {
286
292
  offset: meta.offset ?? null,
287
293
  };
288
294
  const logPath = getLogPath(meta.id);
295
+ const projectDir = this.getProjectDirectory(options.projectId ?? "default");
289
296
  const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
290
297
  status: meta.status,
291
298
  createdAt: meta.createdAt,
@@ -297,7 +304,7 @@ export class LoopManager {
297
304
  nextRunAt: meta.nextRunAt,
298
305
  remainingDelayMs: meta.remainingDelayMs,
299
306
  runHistory: meta.runHistory,
300
- });
307
+ }, projectDir);
301
308
  this.loops.set(meta.id, { controller, options, intervalHuman: meta.intervalHuman });
302
309
  this.wireEvents(meta.id, controller, options, meta.intervalHuman);
303
310
  if (shouldAutoStart(meta.status)) {
@@ -77,6 +77,7 @@ export class ProjectManager {
77
77
  id: "default",
78
78
  name: "Default",
79
79
  color: "#ffffff",
80
+ directory: "",
80
81
  createdAt: new Date().toISOString(),
81
82
  isSystem: true,
82
83
  isDefault: true,
@@ -89,12 +90,13 @@ export class ProjectManager {
89
90
  get(id) {
90
91
  return this.projects.get(id);
91
92
  }
92
- create(name, color) {
93
+ create(name, color, directory) {
93
94
  const id = crypto.randomBytes(4).toString("hex");
94
95
  const project = {
95
96
  id,
96
97
  name,
97
98
  color,
99
+ directory: directory ?? "",
98
100
  createdAt: new Date().toISOString(),
99
101
  isSystem: false,
100
102
  isDefault: false,
@@ -102,7 +104,7 @@ export class ProjectManager {
102
104
  this.saveProject(project);
103
105
  return project;
104
106
  }
105
- update(id, name, color) {
107
+ update(id, name, color, directory) {
106
108
  const project = this.projects.get(id);
107
109
  if (!project)
108
110
  throw new Error(`Project ${id} not found`);
@@ -111,6 +113,8 @@ export class ProjectManager {
111
113
  project.name = name;
112
114
  if (color)
113
115
  project.color = color;
116
+ if (directory !== undefined)
117
+ project.directory = directory;
114
118
  this.saveProject(project);
115
119
  }
116
120
  delete(id) {
@@ -200,23 +200,23 @@ export class IpcServer {
200
200
  break;
201
201
  }
202
202
  case "project-create": {
203
- const { name, color } = request.payload;
203
+ const { name, color, directory } = request.payload;
204
204
  if (!name || !name.trim()) {
205
205
  send(socket, { type: "error", message: t("project.error.nameRequired") });
206
206
  break;
207
207
  }
208
- const project = this.manager.createProject(name.trim(), color);
208
+ const project = this.manager.createProject(name.trim(), color, directory);
209
209
  send(socket, { type: "ok", data: project });
210
210
  break;
211
211
  }
212
212
  case "project-update": {
213
- const { id, name, color } = request.payload;
213
+ const { id, name, color, directory } = request.payload;
214
214
  if (!name || !name.trim()) {
215
215
  send(socket, { type: "error", message: t("project.error.nameEmpty") });
216
216
  break;
217
217
  }
218
218
  try {
219
- this.manager.updateProject(id, name.trim(), color);
219
+ this.manager.updateProject(id, name.trim(), color, directory);
220
220
  send(socket, { type: "ok" });
221
221
  }
222
222
  catch (err) {
package/dist/i18n/en.json CHANGED
@@ -260,7 +260,7 @@
260
260
  "board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d",
261
261
  "board.hintCommand": "Full command line. Quote args with spaces",
262
262
  "board.hintDescription": "Short label shown in the list. Blank uses the command",
263
- "board.hintCwd": "Directory the command runs in. Must exist at run time",
263
+ "board.hintCwd": "Leave blank to inherit from project directory, or set a specific path",
264
264
  "board.hintRunNow": "run once now then every interval, or wait the first interval",
265
265
  "board.hintMaxRuns": "Stop after N runs. Leave blank to run forever",
266
266
  "board.hintTask": "Pick a previously defined task",
@@ -440,6 +440,8 @@
440
440
  "project.wizard.nameHint": "A short name for this project",
441
441
  "project.wizard.colorPrompt": "Pick a color for this project",
442
442
  "project.wizard.colorHint": "Choose a color to identify this project",
443
+ "project.wizard.directoryPrompt": "Working directory? (optional)",
444
+ "project.wizard.directoryHint": "Default directory for loops in this project. Leave blank to inherit",
443
445
  "project.error.updateFailed": "Failed to update project",
444
446
  "project.error.deleteFailed": "Failed to delete project",
445
447
  "project.toastCreated": "Project \"{name}\" created",
@@ -464,6 +466,7 @@
464
466
  "project.headerCreated": "CREATED",
465
467
  "project.inspectorEmpty": " Select a project to view details",
466
468
  "project.fieldId": "ID: ",
469
+ "project.fieldDirectory": "Dir: ",
467
470
  "project.fieldCreated": "Created: ",
468
471
  "project.fieldLoops": "Loops: ",
469
472
  "project.hintSearch": "search",
@@ -573,7 +576,7 @@
573
576
  "wizard.runNowWait": "Wait for the first interval",
574
577
  "wizard.runNowNow": "Run now, then every interval",
575
578
  "wizard.cwdPrompt": "Working directory? (optional)",
576
- "wizard.cwdHint": "Directory the command runs in",
579
+ "wizard.cwdHint": "Leave blank to inherit from project, or set a specific directory",
577
580
  "wizard.descriptionPrompt": "Description?",
578
581
  "wizard.descriptionHint": "Short label shown in the list",
579
582
  "wizard.maxRunsPrompt": "Max runs? (optional)",
package/dist/tui/App.js CHANGED
@@ -655,7 +655,7 @@ export function App(props) {
655
655
  const tabCounts = { loops: loops.length, tasks: tasks.length, projects: projects.length };
656
656
  return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts, activeTab: activeTab, onTabChange: setActiveTab, tabCounts: tabCounts }), _jsx(Box, { flexGrow: 1, 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, tasks: tasks, 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 === "project-create" || view === "project-edit" ? (_jsx(ProjectFormView, { mode: view === "project-edit" ? "edit" : "create", editProject: view === "project-edit" ? editProject : null, onCancel: cancelProject, onDone: onProjectDone })) : (
657
657
  // Board view: left panel + right panel (+ optional debug panel)
658
- _jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: activeTab, query: leftPanelQuery, loops: visible, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, projects: projects, onSelect: (index) => setSelectedIndex(index), onActivate: (index) => { setSelectedIndex(index); }, tasks: filteredTasks, taskSelectedIndex: taskClampedIndex, onTaskSelect: (index) => setTaskSelectedIndex(index), onTaskActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); }, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setActiveTab("projects"), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), projectFilters: projectFilters, projectSelectedIndex: projectClampedIndex, onProjectSelect: (index) => setProjectSelectedIndex(index), onProjectActivate: (index) => { setProjectSelectedIndex(index); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: activeTab, loop: selected, selectedRunIndex: selectedRunIndex, onSelectRun: (index) => setSelectedRunIndex(index), onOpenRun: handleOpenRunLog, selectedTask: selectedTask, allTasks: tasks, selectedProject: selectedProjectEntity, projectLoopCount: projectLoopCount, onProjectEdit: () => { if (selectedProjectEntity && !selectedProjectEntity.isSystem) {
658
+ _jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: activeTab, query: leftPanelQuery, loops: visible, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, projects: projects, onSelect: (index) => setSelectedIndex(index), onActivate: (index) => { setSelectedIndex(index); }, tasks: filteredTasks, taskSelectedIndex: taskClampedIndex, onTaskSelect: (index) => setTaskSelectedIndex(index), onTaskActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); }, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setActiveTab("projects"), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), projectFilters: projectFilters, projectSelectedIndex: projectClampedIndex, onProjectSelect: (index) => setProjectSelectedIndex(index), onProjectActivate: (index) => { setProjectSelectedIndex(index); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: activeTab, loop: selected, selectedRunIndex: selectedRunIndex, onSelectRun: (index) => setSelectedRunIndex(index), onOpenRun: handleOpenRunLog, selectedTask: selectedTask, allTasks: tasks, selectedProject: selectedProjectEntity, projectLoopCount: projectLoopCount, projects: projects, onProjectEdit: () => { if (selectedProjectEntity && !selectedProjectEntity.isSystem) {
659
659
  commandHandlers["edit"]?.();
660
660
  } }, onProjectDelete: () => { if (selectedProjectEntity && !selectedProjectEntity.isSystem) {
661
661
  commandHandlers["delete"]?.();
@@ -3,17 +3,17 @@ import { Box, Text } from "ink";
3
3
  import { darkTheme as theme, statusColor } from "../theme.js";
4
4
  import { describeLoop, commandLine, timeAgo, timeUntil } from "../format.js";
5
5
  import { t } from "../../i18n/index.js";
6
+ import { resolveEffectiveCwd } from "../../core/resolve-cwd.js";
6
7
  const LABEL_WIDTH = 11;
7
8
  function Field(props) {
8
9
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.text.muted, children: props.label.padEnd(LABEL_WIDTH) }), _jsx(Text, { color: theme.text.primary, children: props.children })] }));
9
10
  }
10
11
  const DIVIDER = "\u2500".repeat(40);
11
- /** Muted-label field for identity block (de-emphasized). */
12
12
  function MutedField(props) {
13
13
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.text.muted, children: props.label.padEnd(LABEL_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: props.children })] }));
14
14
  }
15
15
  export function Inspector(props) {
16
- const { loop } = props;
16
+ const { loop, projects } = props;
17
17
  if (!loop) {
18
18
  return (_jsxs(Box, { flexDirection: "column", paddingY: 0, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorEmpty") }) })] }));
19
19
  }
@@ -23,9 +23,11 @@ export function Inspector(props) {
23
23
  const lastExit = loop.lastExitCode !== null ? String(loop.lastExitCode) : t("format.dash");
24
24
  const nextRun = loop.nextRunAt ? t("format.timingNext", { timeAgo: timeUntil(loop.nextRunAt) }) : t("format.dash");
25
25
  const pid = loop.pid ? String(loop.pid) : t("format.dash");
26
- // Dedupe: when description matches the full command line, skip Command field
26
+ const projectDirectory = projects?.find((p) => p.id === loop.projectId)?.directory;
27
+ const effectiveCwd = resolveEffectiveCwd(loop.cwd, projectDirectory);
28
+ const showEffective = loop.cwd !== effectiveCwd;
27
29
  const fullCmd = commandLine(loop.command, loop.commandArgs);
28
30
  const desc = describeLoop(loop);
29
31
  const showCommand = desc !== fullCmd;
30
- return (_jsxs(Box, { flexDirection: "column", paddingY: 0, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.text.muted, children: t("board.fieldStatus").padEnd(LABEL_WIDTH) }), _jsx(Text, { color: sColor, children: loop.status })] }), _jsx(Field, { label: t("board.fieldLastExit"), children: _jsx(Text, { color: theme.text.primary, children: lastExit }) }), _jsx(Field, { label: t("board.fieldLastRun"), children: _jsx(Text, { color: theme.text.primary, children: lastRun }) }), _jsx(Field, { label: t("board.fieldNextRun"), children: _jsx(Text, { color: theme.text.primary, children: nextRun }) }), _jsx(Field, { label: t("board.fieldRuns"), children: _jsxs(Text, { color: theme.text.primary, children: [loop.runCount, " / ", maxRunsLabel] }) }), _jsx(Field, { label: t("board.fieldInterval"), children: _jsx(Text, { color: theme.text.primary, children: loop.intervalHuman }) }), _jsx(Field, { label: t("board.fieldDir"), children: _jsx(Text, { color: theme.text.primary, children: loop.cwd }) }), _jsx(MutedField, { label: t("board.fieldId"), children: loop.id }), _jsx(MutedField, { label: t("board.fieldDesc"), children: desc }), showCommand && _jsx(MutedField, { label: t("board.fieldCommand"), children: fullCmd }), _jsx(MutedField, { label: t("board.fieldTask"), children: loop.taskId ?? t("format.dash") }), _jsx(MutedField, { label: t("board.fieldPid"), children: pid })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) })] }));
32
+ return (_jsxs(Box, { flexDirection: "column", paddingY: 0, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.text.muted, children: t("board.fieldStatus").padEnd(LABEL_WIDTH) }), _jsx(Text, { color: sColor, children: loop.status })] }), _jsx(Field, { label: t("board.fieldLastExit"), children: _jsx(Text, { color: theme.text.primary, children: lastExit }) }), _jsx(Field, { label: t("board.fieldLastRun"), children: _jsx(Text, { color: theme.text.primary, children: lastRun }) }), _jsx(Field, { label: t("board.fieldNextRun"), children: _jsx(Text, { color: theme.text.primary, children: nextRun }) }), _jsx(Field, { label: t("board.fieldRuns"), children: _jsxs(Text, { color: theme.text.primary, children: [loop.runCount, " / ", maxRunsLabel] }) }), _jsx(Field, { label: t("board.fieldInterval"), children: _jsx(Text, { color: theme.text.primary, children: loop.intervalHuman }) }), _jsx(Field, { label: t("board.fieldDir"), children: _jsxs(Text, { color: theme.text.primary, children: [loop.cwd || t("board.inherit"), showEffective ? ` → ${effectiveCwd}` : ""] }) }), _jsx(MutedField, { label: t("board.fieldId"), children: loop.id }), _jsx(MutedField, { label: t("board.fieldDesc"), children: desc }), showCommand && _jsx(MutedField, { label: t("board.fieldCommand"), children: fullCmd }), _jsx(MutedField, { label: t("board.fieldTask"), children: loop.taskId ?? t("format.dash") }), _jsx(MutedField, { label: t("board.fieldPid"), children: pid })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) })] }));
31
33
  }
@@ -35,7 +35,15 @@ export function ProjectFormView(props) {
35
35
  return (_jsx(SelectValueField, { label: value || null, placeholder: t("project.wizard.colorPrompt"), isActive: isActive }));
36
36
  },
37
37
  },
38
- ], [editProject]);
38
+ {
39
+ key: "directory",
40
+ prompt: t("project.wizard.directoryPrompt"),
41
+ hint: t("project.wizard.directoryHint"),
42
+ required: false,
43
+ inputType: "text",
44
+ defaultValue: mode === "create" ? process.cwd() : (editProject?.directory || undefined),
45
+ },
46
+ ], [editProject, mode]);
39
47
  const handleComplete = useCallback((values) => {
40
48
  const name = (values.name ?? "").trim();
41
49
  if (!name)
@@ -43,12 +51,12 @@ export function ProjectFormView(props) {
43
51
  const colorKey = values.color ?? "cyan";
44
52
  const color = PROJECT_COLORS[colorKey] ?? PROJECT_COLORS.cyan;
45
53
  if (mode === "edit" && editProject) {
46
- updateProject(editProject.id, name, color)
54
+ updateProject(editProject.id, name, color, values.directory?.trim() || undefined)
47
55
  .then(() => onDone(true, name))
48
56
  .catch(() => { });
49
57
  }
50
58
  else {
51
- createProject(name, color)
59
+ createProject(name, color, values.directory?.trim() || undefined)
52
60
  .then(() => onDone(false, name))
53
61
  .catch(() => { });
54
62
  }
@@ -91,7 +91,7 @@ export function ProjectsPage(props) {
91
91
  if (p)
92
92
  props.onNavigateEdit(p);
93
93
  }
94
- }, children: null }))] }), _jsx(Box, { flexGrow: 1, flexDirection: "column", marginLeft: 1, children: selected ? (_jsxs(React.Fragment, { children: [_jsxs(Box, { borderStyle: "single", borderColor: theme.accent.brand, padding: 1, flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: selected.color, bold: true, children: "\u25CF" }), _jsx(Text, { color: theme.text.primary, bold: true, children: " " + selected.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "ID: " }), _jsx(Text, { color: theme.text.secondary, children: selected.id })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Created: " }), _jsx(Text, { color: theme.text.secondary, children: selected.createdAt })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Loops: " }), _jsx(Text, { color: theme.text.secondary, children: t("project.loopCount", { count: loopCount }) })] }), selected.isSystem && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.semantic.warning, children: t("project.systemLabel") }) }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(FocusableButton, { label: `${t("project.editProjectLabel")} (${t("project.keyEditHint")})`, color: theme.accent.brand, onPress: () => {
94
+ }, children: null }))] }), _jsx(Box, { flexGrow: 1, flexDirection: "column", marginLeft: 1, children: selected ? (_jsxs(React.Fragment, { children: [_jsxs(Box, { borderStyle: "single", borderColor: theme.accent.brand, padding: 1, flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: selected.color, bold: true, children: "\u25CF" }), _jsx(Text, { color: theme.text.primary, bold: true, children: " " + selected.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "ID: " }), _jsx(Text, { color: theme.text.secondary, children: selected.id })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Created: " }), _jsx(Text, { color: theme.text.secondary, children: selected.createdAt })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Loops: " }), _jsx(Text, { color: theme.text.secondary, children: t("project.loopCount", { count: loopCount }) })] }), selected.directory ? (_jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldDirectory") }), _jsx(Text, { color: theme.text.secondary, children: selected.directory })] })) : null, selected.directory ? (_jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "Dir: " }), _jsx(Text, { color: theme.text.secondary, children: selected.directory })] })) : null, selected.isSystem && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.semantic.warning, children: t("project.systemLabel") }) }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(FocusableButton, { label: `${t("project.editProjectLabel")} (${t("project.keyEditHint")})`, color: theme.accent.brand, onPress: () => {
95
95
  if (props.onNavigateEdit && selected) {
96
96
  props.onNavigateEdit(selected);
97
97
  }
@@ -8,9 +8,9 @@ import { FocusableButton } from "./FocusableButton.js";
8
8
  import { commandLine } from "../format.js";
9
9
  const DIVIDER = "\u2500".repeat(40);
10
10
  export function RightPanel(props) {
11
- const { isFocused, navActive = true, activeTab, loop, selectedRunIndex, onSelectRun, onOpenRun, selectedTask, allTasks, selectedProject, projectLoopCount, onProjectEdit, onProjectDelete, } = props;
11
+ const { isFocused, navActive = true, activeTab, loop, selectedRunIndex, onSelectRun, onOpenRun, selectedTask, allTasks, selectedProject, projectLoopCount, onProjectEdit, onProjectDelete, projects, } = props;
12
12
  const borderColor = isFocused ? tabAccentColor(activeTab) : theme.border.default;
13
- return (_jsx(Box, { flexDirection: "column", width: "40%", borderStyle: "single", borderColor: borderColor, children: activeTab === "projects" ? (_jsx(ProjectInspector, { project: selectedProject ?? null, loopCount: projectLoopCount ?? 0, onEdit: onProjectEdit, onDelete: onProjectDelete })) : activeTab === "tasks" ? (_jsx(TaskInspector, { task: selectedTask ?? null, allTasks: allTasks ?? [] })) : (_jsxs(_Fragment, { children: [_jsx(Inspector, { loop: loop }), _jsx(RunHistory, { loop: loop, selectedRunIndex: selectedRunIndex, onSelectRun: onSelectRun, onOpenRun: onOpenRun, isFocused: isFocused, navActive: navActive })] })) }));
13
+ return (_jsx(Box, { flexDirection: "column", width: "40%", borderStyle: "single", borderColor: borderColor, children: activeTab === "projects" ? (_jsx(ProjectInspector, { project: selectedProject ?? null, loopCount: projectLoopCount ?? 0, onEdit: onProjectEdit, onDelete: onProjectDelete })) : activeTab === "tasks" ? (_jsx(TaskInspector, { task: selectedTask ?? null, allTasks: allTasks ?? [] })) : (_jsxs(_Fragment, { children: [_jsx(Inspector, { loop: loop, projects: projects }), _jsx(RunHistory, { loop: loop, selectedRunIndex: selectedRunIndex, onSelectRun: onSelectRun, onOpenRun: onOpenRun, isFocused: isFocused, navActive: navActive })] })) }));
14
14
  }
15
15
  function TaskInspector(props) {
16
16
  const { task, allTasks } = props;
@@ -30,5 +30,5 @@ function ProjectInspector(props) {
30
30
  if (!project) {
31
31
  return (_jsx(Box, { padding: 1, flexGrow: 1, children: _jsx(Text, { color: theme.text.muted, children: t("project.inspectorEmpty") }) }));
32
32
  }
33
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: project.color, bold: true, children: "\u25CF" }), _jsx(Text, { color: theme.text.primary, bold: true, children: " " + project.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldId") }), _jsx(Text, { color: theme.text.secondary, children: project.id })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldLoops") }), _jsx(Text, { color: theme.text.secondary, children: t("project.loopCount", { count: String(loopCount) }) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldCreated") }), _jsx(Text, { color: theme.text.secondary, children: project.createdAt.slice(0, 10) })] }), project.isSystem && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.semantic.warning, children: t("project.systemLabel") }) })), !project.isSystem && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [onEdit && (_jsx(FocusableButton, { label: `${t("project.editProjectLabel")} (${t("project.keyEditHint")})`, color: theme.accent.project, onPress: onEdit })), onDelete && (_jsx(FocusableButton, { label: `${t("project.deleteProjectLabel")} (${t("project.keyDeleteHint")})`, color: theme.semantic.danger, variant: "danger", onPress: onDelete }))] }))] }));
33
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: project.color, bold: true, children: "\u25CF" }), _jsx(Text, { color: theme.text.primary, bold: true, children: " " + project.name })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldId") }), _jsx(Text, { color: theme.text.secondary, children: project.id })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldLoops") }), _jsx(Text, { color: theme.text.secondary, children: t("project.loopCount", { count: String(loopCount) }) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldCreated") }), _jsx(Text, { color: theme.text.secondary, children: project.createdAt.slice(0, 10) })] }), project.directory && (_jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: t("project.fieldDirectory") }), _jsx(Text, { color: theme.text.secondary, children: project.directory })] })), project.isSystem && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.semantic.warning, children: t("project.systemLabel") }) })), !project.isSystem && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [onEdit && (_jsx(FocusableButton, { label: `${t("project.editProjectLabel")} (${t("project.keyEditHint")})`, color: theme.accent.project, onPress: onEdit })), onDelete && (_jsx(FocusableButton, { label: `${t("project.deleteProjectLabel")} (${t("project.keyDeleteHint")})`, color: theme.semantic.danger, variant: "danger", onPress: onDelete }))] }))] }));
34
34
  }
@@ -104,14 +104,14 @@ export async function listProjects() {
104
104
  throw new Error(response.message);
105
105
  return response.data;
106
106
  }
107
- export async function createProject(name, color) {
108
- const response = await sendRequest({ type: "project-create", payload: { name, color } });
107
+ export async function createProject(name, color, directory) {
108
+ const response = await sendRequest({ type: "project-create", payload: { name, color, directory } });
109
109
  if (response.type !== "ok")
110
110
  throw new Error(response.message);
111
111
  return response.data;
112
112
  }
113
- export async function updateProject(id, name, color) {
114
- const response = await sendRequest({ type: "project-update", payload: { id, name, color } });
113
+ export async function updateProject(id, name, color, directory) {
114
+ const response = await sendRequest({ type: "project-update", payload: { id, name, color, directory } });
115
115
  expectOk(response.message ?? t("project.error.updateFailed"), response.type);
116
116
  }
117
117
  export async function exportConfig() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.0.13",
3
+ "version": "2.0.14",
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": {
@@ -56,6 +56,7 @@
56
56
  "ink-select-input": "^6.2.0",
57
57
  "ink-spinner": "^5.0.0",
58
58
  "ink-text-input": "^6.0.0",
59
+ "inversify-hooks": "^4.0.0",
59
60
  "ms": "^2.1.3",
60
61
  "react": "^19.2.7"
61
62
  },