loop-task 2.1.11 → 2.1.13

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 (68) hide show
  1. package/dist/app/App.js +3 -1
  2. package/dist/cli.js +88 -2
  3. package/dist/client/commands.js +8 -1
  4. package/dist/client/project-commands.js +7 -2
  5. package/dist/core/context/template.js +1 -1
  6. package/dist/core/context/validate-context.js +19 -0
  7. package/dist/core/loop/chain-executor.js +50 -17
  8. package/dist/core/loop/loop-controller.js +18 -0
  9. package/dist/core/loop/loop-runner.js +8 -0
  10. package/dist/core/loop/run-executor.js +1 -1
  11. package/dist/core/scheduling/index.js +4 -0
  12. package/dist/daemon/http/openapi.js +31 -4
  13. package/dist/daemon/http/route-loops.js +106 -0
  14. package/dist/daemon/http/route-misc.js +16 -0
  15. package/dist/daemon/http/route-projects.js +2 -2
  16. package/dist/daemon/http/route-tasks.js +75 -0
  17. package/dist/daemon/http/routes.js +3 -2
  18. package/dist/daemon/http/server.js +20 -2
  19. package/dist/daemon/index.js +48 -7
  20. package/dist/daemon/managers/loop-entry.js +2 -0
  21. package/dist/daemon/managers/loop-manager.js +4 -4
  22. package/dist/daemon/managers/loop-options.js +1 -0
  23. package/dist/daemon/managers/loop-serialization.js +1 -0
  24. package/dist/daemon/managers/project-manager.js +16 -2
  25. package/dist/daemon/mcp/index.js +2 -0
  26. package/dist/daemon/mcp/openapi-sync.js +50 -0
  27. package/dist/daemon/mcp/server.js +162 -0
  28. package/dist/daemon/mcp/tools.js +368 -0
  29. package/dist/daemon/server/handlers/index.js +8 -1
  30. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  31. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  32. package/dist/daemon/server/index.js +3 -1
  33. package/dist/daemon/settings-manager.js +46 -0
  34. package/dist/duration.js +5 -0
  35. package/dist/entities/loops/filters.js +2 -0
  36. package/dist/entities/tasks/filters.js +1 -1
  37. package/dist/features/commands/commands.js +4 -1
  38. package/dist/features/commands/useCommandHandlers.js +32 -0
  39. package/dist/features/commands/useGlobalShortcuts.js +0 -1
  40. package/dist/features/forms/FormRouter.js +2 -0
  41. package/dist/features/overlays/ExportModal.js +1 -1
  42. package/dist/loop-config.js +8 -4
  43. package/dist/shared/clipboard.js +2 -2
  44. package/dist/shared/config/paths.js +3 -0
  45. package/dist/shared/container/index.js +2 -0
  46. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  47. package/dist/shared/hooks/useUndoRedo.js +1 -1
  48. package/dist/shared/i18n/en.json +31 -4
  49. package/dist/shared/services/project-service.js +4 -4
  50. package/dist/shared/services/settings-service.js +49 -0
  51. package/dist/shared/services/types.js +1 -0
  52. package/dist/shared/ui/FocusableInput.js +1 -1
  53. package/dist/shared/ui/SelectModal.js +1 -1
  54. package/dist/shared/ui/format.js +2 -0
  55. package/dist/shared/utils/syntax.js +3 -3
  56. package/dist/widgets/header/Header.js +15 -2
  57. package/dist/widgets/left-panel/Navigator.js +3 -2
  58. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  59. package/dist/widgets/log-modal/LogModal.js +1 -1
  60. package/dist/widgets/loop-form/CreateForm.js +9 -2
  61. package/dist/widgets/loop-form/WizardForm.js +12 -2
  62. package/dist/widgets/loop-form/useCreateSteps.js +30 -2
  63. package/dist/widgets/loop-form/useHandleComplete.js +23 -3
  64. package/dist/widgets/project-form/ProjectForm.js +11 -2
  65. package/dist/widgets/right-panel/Inspector.js +1 -1
  66. package/dist/widgets/right-panel/RightPanel.js +1 -1
  67. package/dist/widgets/task-form/TaskForm.js +69 -4
  68. package/package.json +4 -2
@@ -62,7 +62,7 @@ export function LogModal(props) {
62
62
  const visible = filtered.slice(startIdx, endIdx);
63
63
  useInput((input, key) => {
64
64
  // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
65
- // before the escape checkthe leading ESC trips key.escape and would
65
+ // before the escape check the leading ESC trips key.escape and would
66
66
  // close the modal before the paste is acknowledged. LogModal doesn't
67
67
  // insert pastes, so just swallow the sequence.
68
68
  if (input.includes("\x1b[200~")) {
@@ -15,7 +15,9 @@ export function CreateView(props) {
15
15
  const [taskPickerOpen, setTaskPickerOpen] = useState(false);
16
16
  const [openSelect, setOpenSelect] = useState(null);
17
17
  const [commandEditorOpen, setCommandEditorOpen] = useState(false);
18
+ const [contextEditorOpen, setContextEditorOpen] = useState(false);
18
19
  const [commandValue, setCommandValue] = useState(initial.command ?? "");
20
+ const [contextValue, setContextValue] = useState(initial.context ?? "");
19
21
  const fieldCallbacksRef = useRef({});
20
22
  const taskModeInitial = initial.taskMode === "existing" ? "Existing task" : "Inline command";
21
23
  const resolvedTaskName = useMemo(() => {
@@ -32,10 +34,12 @@ export function CreateView(props) {
32
34
  selectedTaskId,
33
35
  resolvedTaskName,
34
36
  commandValue,
37
+ contextValue,
35
38
  projects: props.projects,
36
39
  setOpenSelect,
37
40
  setTaskPickerOpen,
38
41
  setCommandEditorOpen,
42
+ setContextEditorOpen,
39
43
  fieldCallbacksRef,
40
44
  taskModeInitial,
41
45
  });
@@ -61,7 +65,7 @@ export function CreateView(props) {
61
65
  const selectTitleFor = (field) => field === "taskMode" ? t("wizard.taskModePrompt")
62
66
  : field === "runNow" ? t("wizard.runNowPrompt")
63
67
  : t("wizard.projectPrompt");
64
- return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
68
+ return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen || contextEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
65
69
  onChooseTask({ id: task.id, name: task.name });
66
70
  setTaskPickerOpen(false);
67
71
  }, onClose: () => setTaskPickerOpen(false) })) : null, openSelect ? (_jsx(SelectModal, { title: selectTitleFor(openSelect), options: selectOptionsFor(openSelect), initialValue: fieldCallbacksRef.current[openSelect]?.value, onSelect: (option) => {
@@ -71,5 +75,8 @@ export function CreateView(props) {
71
75
  }, onClose: () => setOpenSelect(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
72
76
  setCommandValue(v);
73
77
  setCommandEditorOpen(false);
74
- }, onCancel: () => setCommandEditorOpen(false) })) : null] }));
78
+ }, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
79
+ setContextValue(v);
80
+ setContextEditorOpen(false);
81
+ }, onCancel: () => setContextEditorOpen(false) })) : null] }));
75
82
  }
@@ -23,7 +23,7 @@ export function WizardForm(props) {
23
23
  result[s.key] = values[s.key] ?? s.defaultValue ?? "";
24
24
  return result;
25
25
  }, [steps, values]);
26
- // Synchronous mirror of resolvedValuesupdated inside setValue so
26
+ // Synchronous mirror of resolvedValues updated inside setValue so
27
27
  // findNextField sees the new value immediately, before React re-renders.
28
28
  // Without this, onChange("Existing task") + onAdvance() run in the same
29
29
  // tick: setValues queues a state update, but findNextField reads stale
@@ -59,6 +59,9 @@ export function WizardForm(props) {
59
59
  const err = validateField(s.key, resolvedValues);
60
60
  if (err)
61
61
  errors[s.key] = err;
62
+ if (s.validate && !s.validate(valueFor(s))) {
63
+ errors[s.key] = s.validationError ?? t("wizard.validationError");
64
+ }
62
65
  }
63
66
  if (Object.keys(errors).length > 0) {
64
67
  setValidationErrors(errors);
@@ -99,6 +102,13 @@ export function WizardForm(props) {
99
102
  setError(step.key, err);
100
103
  return;
101
104
  }
105
+ if (step.validate) {
106
+ const value = valueFor(step);
107
+ if (!step.validate(value)) {
108
+ setError(step.key, step.validationError ?? t("wizard.validationError"));
109
+ return;
110
+ }
111
+ }
102
112
  clearError(step.key);
103
113
  }
104
114
  const next = findNextField(activeField, delta);
@@ -153,7 +163,7 @@ export function WizardForm(props) {
153
163
  setValue(step.key, valueFor(step) + sanitizePaste(input));
154
164
  return;
155
165
  }
156
- // Multi-char containing CR/LF with no bracketed markersignore
166
+ // Multi-char containing CR/LF with no bracketed markers ignore
157
167
  if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
158
168
  return;
159
169
  // Multi-char printable input = unbracketed single-line paste (e.g. right-click)
@@ -3,8 +3,9 @@ import { useMemo } from "react";
3
3
  import { t } from "../../shared/i18n/index.js";
4
4
  import { SelectValueField } from "../../shared/ui/SelectModal.js";
5
5
  import { CodeEditorPreview } from "../../features/code-editor/CodeEditorPreview.js";
6
+ import { validateContext } from "../../core/context/validate-context.js";
6
7
  export function useCreateSteps(params) {
7
- const { initial, selectedTaskId, resolvedTaskName, commandValue, projects, setOpenSelect, setTaskPickerOpen, setCommandEditorOpen, fieldCallbacksRef, taskModeInitial, } = params;
8
+ const { initial, selectedTaskId, resolvedTaskName, commandValue, contextValue, projects, setOpenSelect, setTaskPickerOpen, setCommandEditorOpen, setContextEditorOpen, fieldCallbacksRef, taskModeInitial, } = params;
8
9
  return useMemo(() => {
9
10
  const list = [
10
11
  {
@@ -59,6 +60,10 @@ export function useCreateSteps(params) {
59
60
  defaultValue: initial.runNow === "true" || initial.runNow === "yes"
60
61
  ? t("wizard.runNowNow")
61
62
  : t("wizard.runNowWait"),
63
+ skip: (values) => {
64
+ const v = (values.interval ?? "").trim().toLowerCase();
65
+ return v === "manual" || v === "0";
66
+ },
62
67
  onActivate: () => setOpenSelect("runNow"),
63
68
  renderCustom: ({ value, isActive, onChange, onAdvance }) => {
64
69
  fieldCallbacksRef.current.runNow = { value, onChange, onAdvance };
@@ -101,7 +106,30 @@ export function useCreateSteps(params) {
101
106
  return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.projectPrompt"), isActive: isActive }));
102
107
  },
103
108
  },
109
+ {
110
+ key: "context",
111
+ prompt: t("wizard.contextPrompt"),
112
+ hint: t("wizard.contextHint"),
113
+ required: false,
114
+ inputType: "text",
115
+ defaultValue: contextValue || undefined,
116
+ onActivate: () => setContextEditorOpen(true),
117
+ validate: (value) => {
118
+ if (!value?.trim())
119
+ return true;
120
+ try {
121
+ const parsed = JSON.parse(value);
122
+ const result = validateContext(parsed);
123
+ return result.valid;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ },
129
+ validationError: t("wizard.contextInvalid"),
130
+ renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: contextValue, hint: t("wizard.contextHint"), isActive: isActive, onActivate: () => setContextEditorOpen(true) })),
131
+ },
104
132
  ];
105
133
  return list;
106
- }, [taskModeInitial, selectedTaskId, resolvedTaskName, initial, commandValue, projects]);
134
+ }, [taskModeInitial, selectedTaskId, resolvedTaskName, initial, commandValue, contextValue, projects]);
107
135
  }
@@ -2,6 +2,7 @@ import { useCallback } from "react";
2
2
  import { t } from "../../shared/i18n/index.js";
3
3
  import { parseDuration } from "../../duration.js";
4
4
  import { parseCommandLine, joinCommandLines } from "../../loop-config.js";
5
+ import { validateContext } from "../../core/context/validate-context.js";
5
6
  export function useHandleComplete(params) {
6
7
  const { selectedTaskId, mode, editId, currentProjectId, onDone, commandValue, projects, loopService, } = params;
7
8
  return useCallback((values) => {
@@ -15,7 +16,8 @@ export function useHandleComplete(params) {
15
16
  catch {
16
17
  return;
17
18
  }
18
- const intervalHuman = intervalInput.trim();
19
+ const isManual = interval === 0;
20
+ const intervalHuman = isManual ? "manual" : intervalInput.trim();
19
21
  const isExistingTask = !!values.taskMode?.includes("Existing");
20
22
  if (isExistingTask && !selectedTaskId && !values.taskId?.trim())
21
23
  return;
@@ -41,6 +43,21 @@ export function useHandleComplete(params) {
41
43
  const projectName = values.project ?? "";
42
44
  const project = projects.find((p) => p.name === projectName);
43
45
  const projectId = project?.id ?? currentProjectId;
46
+ let context;
47
+ const contextStr = (values.context ?? "").trim();
48
+ if (contextStr) {
49
+ try {
50
+ const parsed = JSON.parse(contextStr);
51
+ const result = validateContext(parsed);
52
+ if (result.valid) {
53
+ context = result.context;
54
+ }
55
+ }
56
+ catch {
57
+ // invalid context, skip
58
+ }
59
+ }
60
+ const cwdInput = (values.cwd ?? "").trim();
44
61
  const options = {
45
62
  interval,
46
63
  taskId: isExistingTask
@@ -49,8 +66,10 @@ export function useHandleComplete(params) {
49
66
  command: cmdOnly,
50
67
  commandArgs: args,
51
68
  commandRaw: isExistingTask ? undefined : cmdValue,
52
- cwd: (values.cwd ?? "").trim() || process.cwd(),
53
- immediate: runNowValue,
69
+ // On create, default an empty cwd to the current directory. On edit,
70
+ // preserve an empty cwd, the user deliberately cleared/kept it empty.
71
+ cwd: cwdInput || (mode === "edit" ? "" : process.cwd()),
72
+ immediate: isManual ? false : runNowValue,
54
73
  maxRuns: (values.maxRuns ?? "").trim()
55
74
  ? parseInt(values.maxRuns, 10)
56
75
  : null,
@@ -58,6 +77,7 @@ export function useHandleComplete(params) {
58
77
  description: (values.description ?? "").trim(),
59
78
  projectId,
60
79
  offset: null,
80
+ context,
61
81
  };
62
82
  const desc = (values.description ?? "").trim() || [cmdOnly, ...args].join(" ").trim();
63
83
  if (mode === "edit" && editId) {
@@ -45,6 +45,14 @@ export function ProjectFormView(props) {
45
45
  inputType: "text",
46
46
  defaultValue: mode === "create" ? process.cwd() : (editProject?.directory || undefined),
47
47
  },
48
+ {
49
+ key: "githubSource",
50
+ prompt: t("project.wizard.githubSourcePrompt"),
51
+ hint: t("project.wizard.githubSourceHint"),
52
+ required: false,
53
+ inputType: "text",
54
+ defaultValue: editProject?.githubSource || undefined,
55
+ },
48
56
  ], [editProject, mode]);
49
57
  const handleComplete = useCallback((values) => {
50
58
  const name = (values.name ?? "").trim();
@@ -52,13 +60,14 @@ export function ProjectFormView(props) {
52
60
  return;
53
61
  const colorKey = values.color ?? "cyan";
54
62
  const color = PROJECT_COLORS[colorKey] ?? PROJECT_COLORS.cyan;
63
+ const githubSource = values.githubSource?.trim() || undefined;
55
64
  if (mode === "edit" && editProject) {
56
- projectService.update(editProject.id, name, color, values.directory?.trim() || undefined)
65
+ projectService.update(editProject.id, name, color, values.directory?.trim() || undefined, githubSource)
57
66
  .then(() => onDone(true, name))
58
67
  .catch(() => { });
59
68
  }
60
69
  else {
61
- projectService.create(name, color, values.directory?.trim() || undefined)
70
+ projectService.create(name, color, values.directory?.trim() || undefined, githubSource)
62
71
  .then(() => onDone(false, name))
63
72
  .catch(() => { });
64
73
  }
@@ -23,5 +23,5 @@ export function Inspector(props) {
23
23
  const nextRun = loop.nextRunAt ? t("format.timingNext", { timeAgo: timeUntil(loop.nextRunAt) }) : t("format.dash");
24
24
  const fullCmd = truncate(commandLine(loop.command, loop.commandArgs), 38);
25
25
  const desc = truncate(describeLoop(loop), 38);
26
- 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.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.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(MutedField, { label: t("board.fieldDesc"), children: desc }), _jsx(MutedField, { label: t("board.fieldCommand"), children: fullCmd })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) })] }));
26
+ 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.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.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 }) }), (loop.silentChainCount ?? 0) > 0 ? (_jsx(Field, { label: t("board.fieldSilentChains"), children: _jsx(Text, { color: theme.text.muted, children: t("board.silentChainCount", { count: (loop.silentChainCount ?? 0).toLocaleString() }) }) })) : null, _jsx(MutedField, { label: t("board.fieldDesc"), children: desc }), _jsx(MutedField, { label: t("board.fieldCommand"), children: fullCmd })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) })] }));
27
27
  }
@@ -21,7 +21,7 @@ function TaskInspector(props) {
21
21
  }
22
22
  return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingY: 0, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.accent.task, bold: true, children: t("board.taskInspectorTitle") }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Field, { label: t("board.taskFieldName"), children: _jsx(Text, { color: theme.accent.task, bold: true, children: task.name }) }), _jsx(Field, { label: t("board.taskFieldId"), children: _jsx(Text, { color: theme.text.primary, children: task.id }) }), _jsx(Field, { label: t("board.taskFieldCommand"), children: _jsx(Text, { color: theme.text.primary, children: task.commandRaw
23
23
  ? task.commandRaw.split("\n").filter(Boolean).join(" ")
24
- : commandLine(task.command, task.commandArgs) }) }), _jsx(Field, { label: t("board.taskFieldCreated"), children: _jsx(Text, { color: theme.text.primary, children: task.createdAt.slice(0, 10) }) }), _jsxs(Field, { label: t("board.taskFieldChain"), children: [task.onSuccessTaskId ? (_jsx(Text, { color: theme.semantic.success, children: "\u2713 " + (allTasks.find((t) => t.id === task.onSuccessTaskId)?.name ?? task.onSuccessTaskId) })) : null, task.onFailureTaskId ? (_jsx(Text, { color: theme.semantic.danger, children: " \u2192 " + (allTasks.find((t) => t.id === task.onFailureTaskId)?.name ?? task.onFailureTaskId) })) : null, !task.onSuccessTaskId && !task.onFailureTaskId ? (_jsx(Text, { color: theme.text.muted, children: t("board.taskNone") })) : null] })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) })] }));
24
+ : commandLine(task.command, task.commandArgs) }) }), _jsx(Field, { label: t("board.taskFieldCreated"), children: _jsx(Text, { color: theme.text.primary, children: task.createdAt.slice(0, 10) }) }), _jsxs(Field, { label: t("board.taskFieldChain"), children: [task.onSuccessTaskId ? (_jsx(Text, { color: theme.semantic.success, children: "\u2713 " + (allTasks.find((t) => t.id === task.onSuccessTaskId)?.name ?? task.onSuccessTaskId) })) : null, task.onFailureTaskId ? (_jsx(Text, { color: theme.semantic.danger, children: " \u2192 " + (allTasks.find((t) => t.id === task.onFailureTaskId)?.name ?? task.onFailureTaskId) })) : null, !task.onSuccessTaskId && !task.onFailureTaskId ? (_jsx(Text, { color: theme.text.muted, children: t("board.taskNone") })) : null] }), _jsx(Field, { label: t("board.taskFieldSilent"), children: _jsx(Text, { color: task.silentChain ? theme.semantic.warning : theme.text.muted, children: task.silentChain ? t("board.silentChainYes") : t("board.silentChainNo") }) })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: DIVIDER }) })] }));
25
25
  }
26
26
  const LABEL_WIDTH = 9;
27
27
  function Field(props) {
@@ -6,6 +6,7 @@ import { CodeEditorPreview } from "../../features/code-editor/CodeEditorPreview.
6
6
  import { CodeEditorModal } from "../../features/code-editor/CodeEditorModal.js";
7
7
  import { useInject } from "../../shared/hooks/useInject.js";
8
8
  import { TYPES } from "../../shared/services/types.js";
9
+ import { validateContext } from "../../core/context/validate-context.js";
9
10
  import crypto from "node:crypto";
10
11
  import { t } from "../../shared/i18n/index.js";
11
12
  import { joinCommandLines, parseCommandLine } from "../../loop-config.js";
@@ -36,7 +37,11 @@ export function TaskForm(props) {
36
37
  const chainOptions = useMemo(() => [t("wizard.chainNone"), ...tasks.map((task) => task.name)], [tasks]);
37
38
  const chainSelectOptions = useMemo(() => chainOptions.map((v) => ({ value: v, label: v })), [chainOptions]);
38
39
  const [commandEditorOpen, setCommandEditorOpen] = useState(false);
40
+ const [contextEditorOpen, setContextEditorOpen] = useState(false);
41
+ const [contextValue, setContextValue] = useState(editTask?.context ? JSON.stringify(editTask.context) : "");
42
+ const [silentChainValue, setSilentChainValue] = useState(editTask?.silentChain ? t("board.silentChainYes") : "");
39
43
  const [openChainField, setOpenChainField] = useState(null);
44
+ const [openSilentChain, setOpenSilentChain] = useState(false);
40
45
  const chainFieldsRef = useRef({});
41
46
  const resolveChainId = useCallback((val) => {
42
47
  if (!val || val === t("wizard.chainNone"))
@@ -94,9 +99,41 @@ export function TaskForm(props) {
94
99
  return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.onFailurePrompt"), isActive: isActive }));
95
100
  },
96
101
  },
102
+ {
103
+ key: "silentChain",
104
+ prompt: t("wizard.silentChainPrompt"),
105
+ hint: t("wizard.silentChainHint"),
106
+ required: false,
107
+ defaultValue: silentChainValue || undefined,
108
+ onActivate: () => setOpenSilentChain(true),
109
+ renderCustom: ({ value, isActive }) => (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.silentChainPrompt"), isActive: isActive })),
110
+ },
111
+ {
112
+ key: "context",
113
+ prompt: t("wizard.contextPrompt"),
114
+ hint: t("wizard.contextHint"),
115
+ required: false,
116
+ inputType: "text",
117
+ defaultValue: contextValue || undefined,
118
+ onActivate: () => setContextEditorOpen(true),
119
+ validate: (value) => {
120
+ if (!value?.trim())
121
+ return true;
122
+ try {
123
+ const parsed = JSON.parse(value);
124
+ const result = validateContext(parsed);
125
+ return result.valid;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ },
131
+ validationError: t("wizard.contextInvalid"),
132
+ renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: contextValue, hint: t("wizard.contextHint"), isActive: isActive, onActivate: () => setContextEditorOpen(true) })),
133
+ },
97
134
  ];
98
135
  return list;
99
- }, [commandValue, chainOptions, editTask, resolveChainName]);
136
+ }, [commandValue, chainOptions, editTask, resolveChainName, contextValue, silentChainValue]);
100
137
  const handleComplete = useCallback((values) => {
101
138
  const name = values.name?.trim() ?? "";
102
139
  const rawCommand = joinCommandLines(commandValue);
@@ -104,6 +141,21 @@ export function TaskForm(props) {
104
141
  return;
105
142
  const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
106
143
  const onFailureTaskId = resolveChainId(values.onFailure ?? "");
144
+ const silentChain = values.silentChain === t("board.silentChainYes");
145
+ let context;
146
+ const contextStr = contextValue?.trim();
147
+ if (contextStr) {
148
+ try {
149
+ const parsed = JSON.parse(contextStr);
150
+ const result = validateContext(parsed);
151
+ if (result.valid) {
152
+ context = result.context;
153
+ }
154
+ }
155
+ catch {
156
+ // invalid context, skip
157
+ }
158
+ }
107
159
  const commandSegments = commandValue
108
160
  .split(/\n&&\n|\n&&|&&\n|&&/)
109
161
  .map(s => s.trim())
@@ -136,6 +188,8 @@ export function TaskForm(props) {
136
188
  steps,
137
189
  onSuccessTaskId,
138
190
  onFailureTaskId,
191
+ silentChain: silentChain || undefined,
192
+ context,
139
193
  };
140
194
  }
141
195
  else {
@@ -146,6 +200,8 @@ export function TaskForm(props) {
146
200
  commandRaw: commandValue,
147
201
  onSuccessTaskId,
148
202
  onFailureTaskId,
203
+ silentChain: silentChain || undefined,
204
+ context,
149
205
  };
150
206
  }
151
207
  if (mode === "edit" && editTask) {
@@ -159,16 +215,25 @@ export function TaskForm(props) {
159
215
  .then((result) => onDone(false, result.id))
160
216
  .catch(() => { });
161
217
  }
162
- }, [commandValue, resolveChainId, mode, editTask, onDone]);
218
+ }, [commandValue, resolveChainId, mode, editTask, onDone, contextValue, silentChainValue]);
163
219
  const title = mode === "edit"
164
220
  ? t("board.taskEditTitle")
165
221
  : t("board.taskCreateTitle");
166
- return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
222
+ return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen || contextEditorOpen || openSilentChain }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
167
223
  chainFieldsRef.current[openChainField]?.onChange(option.value);
168
224
  chainFieldsRef.current[openChainField]?.onAdvance();
169
225
  setOpenChainField(null);
170
226
  }, onClose: () => setOpenChainField(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
171
227
  setCommandValue(v);
172
228
  setCommandEditorOpen(false);
173
- }, onCancel: () => setCommandEditorOpen(false) })) : null] }));
229
+ }, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
230
+ setContextValue(v);
231
+ setContextEditorOpen(false);
232
+ }, onCancel: () => setContextEditorOpen(false) })) : null, openSilentChain ? (_jsx(SelectModal, { title: t("wizard.silentChainPrompt"), options: [
233
+ { value: t("board.silentChainNo"), label: t("board.silentChainNo") },
234
+ { value: t("board.silentChainYes"), label: t("board.silentChainYes") },
235
+ ], initialValue: silentChainValue || t("board.silentChainNo"), onSelect: (option) => {
236
+ setSilentChainValue(option.value === t("board.silentChainNo") ? "" : option.value);
237
+ setOpenSilentChain(false);
238
+ }, onClose: () => setOpenSilentChain(false) })) : null] }));
174
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.1.11",
3
+ "version": "2.1.13",
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": {
@@ -48,6 +48,7 @@
48
48
  "node": ">=20.0.0"
49
49
  },
50
50
  "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
52
  "commander": "^13.1.0",
52
53
  "execa": "^9.6.0",
53
54
  "ink": "^7.1.0",
@@ -59,7 +60,8 @@
59
60
  "inversify": "^8.1.1",
60
61
  "inversify-hooks": "^4.0.0",
61
62
  "ms": "^2.1.3",
62
- "react": "^19.2.7"
63
+ "react": "^19.2.7",
64
+ "zod": "^4.4.3"
63
65
  },
64
66
  "devDependencies": {
65
67
  "@types/ms": "^2.1.0",