loop-task 2.1.12 → 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 (57) hide show
  1. package/dist/app/App.js +3 -1
  2. package/dist/cli.js +44 -2
  3. package/dist/client/commands.js +4 -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 +2 -2
  7. package/dist/core/loop/chain-executor.js +11 -4
  8. package/dist/core/loop/loop-controller.js +9 -0
  9. package/dist/daemon/http/openapi.js +29 -2
  10. package/dist/daemon/http/route-loops.js +85 -0
  11. package/dist/daemon/http/route-misc.js +16 -0
  12. package/dist/daemon/http/route-projects.js +2 -2
  13. package/dist/daemon/http/route-tasks.js +58 -0
  14. package/dist/daemon/http/routes.js +3 -2
  15. package/dist/daemon/http/server.js +20 -2
  16. package/dist/daemon/index.js +48 -7
  17. package/dist/daemon/managers/loop-entry.js +2 -0
  18. package/dist/daemon/managers/loop-manager.js +4 -4
  19. package/dist/daemon/managers/project-manager.js +16 -2
  20. package/dist/daemon/mcp/index.js +2 -0
  21. package/dist/daemon/mcp/openapi-sync.js +50 -0
  22. package/dist/daemon/mcp/server.js +162 -0
  23. package/dist/daemon/mcp/tools.js +368 -0
  24. package/dist/daemon/server/handlers/index.js +8 -1
  25. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  26. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  27. package/dist/daemon/server/index.js +3 -1
  28. package/dist/daemon/settings-manager.js +46 -0
  29. package/dist/entities/tasks/filters.js +1 -1
  30. package/dist/features/commands/commands.js +4 -1
  31. package/dist/features/commands/useCommandHandlers.js +32 -0
  32. package/dist/features/commands/useGlobalShortcuts.js +0 -1
  33. package/dist/features/overlays/ExportModal.js +1 -1
  34. package/dist/loop-config.js +1 -1
  35. package/dist/shared/clipboard.js +2 -2
  36. package/dist/shared/config/paths.js +3 -0
  37. package/dist/shared/container/index.js +2 -0
  38. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  39. package/dist/shared/hooks/useUndoRedo.js +1 -1
  40. package/dist/shared/i18n/en.json +23 -0
  41. package/dist/shared/services/project-service.js +4 -4
  42. package/dist/shared/services/settings-service.js +49 -0
  43. package/dist/shared/services/types.js +1 -0
  44. package/dist/shared/ui/FocusableInput.js +1 -1
  45. package/dist/shared/ui/SelectModal.js +1 -1
  46. package/dist/shared/utils/syntax.js +3 -3
  47. package/dist/widgets/header/Header.js +15 -2
  48. package/dist/widgets/left-panel/Navigator.js +3 -2
  49. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  50. package/dist/widgets/log-modal/LogModal.js +1 -1
  51. package/dist/widgets/loop-form/WizardForm.js +2 -2
  52. package/dist/widgets/loop-form/useHandleComplete.js +4 -1
  53. package/dist/widgets/project-form/ProjectForm.js +11 -2
  54. package/dist/widgets/right-panel/Inspector.js +1 -1
  55. package/dist/widgets/right-panel/RightPanel.js +1 -1
  56. package/dist/widgets/task-form/TaskForm.js +24 -4
  57. package/package.json +4 -2
@@ -0,0 +1,368 @@
1
+ import { z } from "zod";
2
+ import { buildLoopOptions } from "../../loop-config.js";
3
+ import { validateContext } from "../../core/context/validate-context.js";
4
+ import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
5
+ import { tail } from "../../shared/tail.js";
6
+ import fs from "node:fs";
7
+ function ok(data) {
8
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, data }) }] };
9
+ }
10
+ function err(message) {
11
+ return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: { message } }) }], isError: true };
12
+ }
13
+ export function registerMcpTools(server, deps) {
14
+ const { manager, taskManager, projectManager } = deps;
15
+ server.registerTool("list_loops", { description: "List all loops" }, async () => {
16
+ return ok(manager.list());
17
+ });
18
+ server.registerTool("get_loop", { description: "Get loop status by ID", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
19
+ const meta = manager.status(args.id);
20
+ if (!meta)
21
+ return err(`Loop not found: ${args.id}`);
22
+ return ok(meta);
23
+ });
24
+ server.registerTool("create_loop", {
25
+ description: "Create a new loop",
26
+ inputSchema: {
27
+ command: z.string().optional().describe("Command to run"),
28
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
29
+ intervalHuman: z.string().default("5m").describe("Interval like 30s, 5m, 1h, or 'manual' for trigger-only loops"),
30
+ cwd: z.string().optional().describe("Working directory"),
31
+ description: z.string().optional().describe("Loop description"),
32
+ taskId: z.string().nullable().optional().describe("Task ID to use instead of command"),
33
+ now: z.boolean().optional().describe("Run immediately on start"),
34
+ maxRuns: z.number().nullable().optional().describe("Maximum number of runs"),
35
+ verbose: z.boolean().optional().describe("Verbose output"),
36
+ projectId: z.string().optional().describe("Project ID"),
37
+ offset: z.number().nullable().optional().describe("Offset in ms from interval boundary"),
38
+ context: z.record(z.string(), z.unknown()).optional().describe("Initial context for chain interpolation"),
39
+ },
40
+ }, async (args) => {
41
+ try {
42
+ let context;
43
+ if (args.context !== undefined) {
44
+ const result = validateContext(args.context);
45
+ if (!result.valid)
46
+ return err(result.error);
47
+ context = result.context;
48
+ }
49
+ const intervalHuman = args.intervalHuman ?? "5m";
50
+ const { options } = buildLoopOptions(intervalHuman, {
51
+ command: args.command,
52
+ commandArgs: args.commandArgs,
53
+ taskId: args.taskId,
54
+ cwd: args.cwd,
55
+ now: args.now,
56
+ maxRuns: args.maxRuns,
57
+ verbose: args.verbose,
58
+ description: args.description,
59
+ projectId: args.projectId,
60
+ offset: args.offset,
61
+ context,
62
+ });
63
+ const id = manager.start(options, intervalHuman);
64
+ return ok({ id });
65
+ }
66
+ catch (e) {
67
+ return err(e instanceof Error ? e.message : String(e));
68
+ }
69
+ });
70
+ server.registerTool("update_loop", {
71
+ description: "Update an existing loop",
72
+ inputSchema: {
73
+ id: z.string().describe("Loop ID"),
74
+ command: z.string().optional().describe("Command to run"),
75
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
76
+ intervalHuman: z.string().default("5m").describe("Interval like 30s, 5m, 1h, or 'manual'"),
77
+ cwd: z.string().optional().describe("Working directory"),
78
+ description: z.string().optional().describe("Loop description"),
79
+ taskId: z.string().nullable().optional().describe("Task ID"),
80
+ now: z.boolean().optional().describe("Run immediately"),
81
+ maxRuns: z.number().nullable().optional().describe("Maximum runs"),
82
+ verbose: z.boolean().optional().describe("Verbose output"),
83
+ projectId: z.string().optional().describe("Project ID"),
84
+ offset: z.number().nullable().optional().describe("Offset in ms"),
85
+ context: z.record(z.string(), z.unknown()).optional().describe("Context for chain interpolation"),
86
+ },
87
+ }, async (args) => {
88
+ try {
89
+ const id = args.id;
90
+ let context;
91
+ if (args.context !== undefined) {
92
+ const result = validateContext(args.context);
93
+ if (!result.valid)
94
+ return err(result.error);
95
+ context = result.context;
96
+ }
97
+ const intervalHuman = args.intervalHuman ?? "5m";
98
+ const { options } = buildLoopOptions(intervalHuman, {
99
+ command: args.command,
100
+ commandArgs: args.commandArgs,
101
+ taskId: args.taskId,
102
+ cwd: args.cwd,
103
+ now: args.now,
104
+ maxRuns: args.maxRuns,
105
+ verbose: args.verbose,
106
+ description: args.description,
107
+ projectId: args.projectId,
108
+ offset: args.offset,
109
+ context,
110
+ });
111
+ const updated = await manager.update(id, options, intervalHuman);
112
+ if (!updated)
113
+ return err(`Loop not found: ${id}`);
114
+ return ok({ id });
115
+ }
116
+ catch (e) {
117
+ return err(e instanceof Error ? e.message : String(e));
118
+ }
119
+ });
120
+ server.registerTool("delete_loop", { description: "Delete a loop by ID", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
121
+ const deleted = await manager.delete(args.id);
122
+ if (!deleted)
123
+ return err(`Loop not found: ${args.id}`);
124
+ return ok();
125
+ });
126
+ server.registerTool("pause_loop", { description: "Pause a loop", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
127
+ if (!manager.pause(args.id))
128
+ return err(`Loop not found or cannot pause: ${args.id}`);
129
+ return ok();
130
+ });
131
+ server.registerTool("resume_loop", { description: "Resume a paused loop", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
132
+ if (!manager.resume(args.id))
133
+ return err(`Loop not found or cannot resume: ${args.id}`);
134
+ return ok();
135
+ });
136
+ server.registerTool("trigger_loop", { description: "Trigger a loop to run immediately", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
137
+ const id = args.id;
138
+ if (manager.isMaxRunsBlocked(id))
139
+ return err("Max runs reached");
140
+ if (manager.isRunning(id))
141
+ return err("Loop is already running");
142
+ if (!manager.trigger(id))
143
+ return err(`Loop not found: ${id}`);
144
+ return ok();
145
+ });
146
+ server.registerTool("stop_loop", { description: "Stop a running loop", inputSchema: { id: z.string().describe("Loop ID") } }, async (args) => {
147
+ if (!manager.stopLoop(args.id))
148
+ return err(`Loop not found or cannot stop: ${args.id}`);
149
+ return ok();
150
+ });
151
+ server.registerTool("stop_all_loops", { description: "Stop all running loops" }, async () => {
152
+ const count = manager.stopAllLoops();
153
+ return ok({ count });
154
+ });
155
+ server.registerTool("list_tasks", { description: "List all tasks" }, async () => {
156
+ return ok(taskManager.list());
157
+ });
158
+ server.registerTool("get_task", { description: "Get a task by ID", inputSchema: { id: z.string().describe("Task ID") } }, async (args) => {
159
+ const task = taskManager.get(args.id);
160
+ if (!task)
161
+ return err(`Task not found: ${args.id}`);
162
+ return ok(task);
163
+ });
164
+ server.registerTool("create_task", {
165
+ description: "Create a new task",
166
+ inputSchema: {
167
+ id: z.string().optional().describe("Task ID (auto-generated if omitted)"),
168
+ name: z.string().describe("Task name"),
169
+ command: z.string().describe("Command to run"),
170
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
171
+ commandRaw: z.string().optional().describe("Raw command string"),
172
+ steps: z.array(z.object({ commands: z.array(z.object({ command: z.string(), commandArgs: z.array(z.string()) })) })).optional().describe("Task steps"),
173
+ onSuccessTaskId: z.string().nullable().optional().describe("Task to run on success"),
174
+ onFailureTaskId: z.string().nullable().optional().describe("Task to run on failure"),
175
+ silentChain: z.boolean().optional().describe("Suppress chain output"),
176
+ context: z.record(z.string(), z.unknown()).optional().describe("Context for chain interpolation"),
177
+ },
178
+ }, async (args) => {
179
+ try {
180
+ const name = args.name;
181
+ const command = args.command;
182
+ if (!name?.trim())
183
+ return err("Task name is required");
184
+ if (!command?.trim())
185
+ return err("Task command is required");
186
+ const input = {
187
+ id: args.id ?? "",
188
+ name,
189
+ command,
190
+ commandArgs: args.commandArgs ?? [],
191
+ commandRaw: args.commandRaw,
192
+ steps: args.steps,
193
+ onSuccessTaskId: args.onSuccessTaskId ?? null,
194
+ onFailureTaskId: args.onFailureTaskId ?? null,
195
+ silentChain: args.silentChain,
196
+ context: args.context,
197
+ };
198
+ if (input.context !== undefined) {
199
+ const result = validateContext(input.context);
200
+ if (!result.valid)
201
+ return err(result.error);
202
+ input.context = result.context;
203
+ }
204
+ const task = taskManager.create(input);
205
+ return ok(task);
206
+ }
207
+ catch (e) {
208
+ return err(e instanceof Error ? e.message : String(e));
209
+ }
210
+ });
211
+ server.registerTool("update_task", {
212
+ description: "Update an existing task",
213
+ inputSchema: {
214
+ id: z.string().describe("Task ID"),
215
+ name: z.string().describe("Task name"),
216
+ command: z.string().describe("Command to run"),
217
+ commandArgs: z.array(z.string()).optional().describe("Command arguments"),
218
+ commandRaw: z.string().optional().describe("Raw command string"),
219
+ steps: z.array(z.object({ commands: z.array(z.object({ command: z.string(), commandArgs: z.array(z.string()) })) })).optional().describe("Task steps"),
220
+ onSuccessTaskId: z.string().nullable().optional().describe("Task to run on success"),
221
+ onFailureTaskId: z.string().nullable().optional().describe("Task to run on failure"),
222
+ silentChain: z.boolean().optional().describe("Suppress chain output"),
223
+ context: z.record(z.string(), z.unknown()).optional().describe("Context for chain interpolation"),
224
+ },
225
+ }, async (args) => {
226
+ try {
227
+ const id = args.id;
228
+ const name = args.name;
229
+ const command = args.command;
230
+ if (!name?.trim())
231
+ return err("Task name is required");
232
+ if (!command?.trim())
233
+ return err("Task command is required");
234
+ const input = {
235
+ name,
236
+ command,
237
+ commandArgs: args.commandArgs ?? [],
238
+ commandRaw: args.commandRaw,
239
+ steps: args.steps,
240
+ onSuccessTaskId: args.onSuccessTaskId ?? null,
241
+ onFailureTaskId: args.onFailureTaskId ?? null,
242
+ silentChain: args.silentChain,
243
+ context: args.context,
244
+ };
245
+ if (input.context !== undefined) {
246
+ const result = validateContext(input.context);
247
+ if (!result.valid)
248
+ return err(result.error);
249
+ input.context = result.context;
250
+ }
251
+ const updated = taskManager.update(id, input);
252
+ if (!updated)
253
+ return err(`Task not found: ${id}`);
254
+ return ok(updated);
255
+ }
256
+ catch (e) {
257
+ return err(e instanceof Error ? e.message : String(e));
258
+ }
259
+ });
260
+ server.registerTool("delete_task", { description: "Delete a task by ID", inputSchema: { id: z.string().describe("Task ID") } }, async (args) => {
261
+ if (!taskManager.delete(args.id))
262
+ return err(`Task not found: ${args.id}`);
263
+ return ok();
264
+ });
265
+ server.registerTool("list_projects", { description: "List all projects" }, async () => {
266
+ return ok(projectManager.getAll());
267
+ });
268
+ server.registerTool("get_project", { description: "Get a project by ID", inputSchema: { id: z.string().describe("Project ID") } }, async (args) => {
269
+ const project = projectManager.get(args.id);
270
+ if (!project)
271
+ return err(`Project not found: ${args.id}`);
272
+ return ok(project);
273
+ });
274
+ server.registerTool("create_project", {
275
+ description: "Create a new project",
276
+ inputSchema: {
277
+ name: z.string().describe("Project name"),
278
+ color: z.string().default("#ffffff").describe("Project color (hex)"),
279
+ directory: z.string().optional().describe("Local working directory for loops"),
280
+ githubSource: z.string().optional().describe("GitHub repository in owner/repo format"),
281
+ },
282
+ }, async (args) => {
283
+ try {
284
+ const name = args.name;
285
+ if (!name?.trim())
286
+ return err("Project name is required");
287
+ const project = projectManager.create(name.trim(), args.color ?? "#ffffff", args.directory, args.githubSource);
288
+ return ok(project);
289
+ }
290
+ catch (e) {
291
+ return err(e instanceof Error ? e.message : String(e));
292
+ }
293
+ });
294
+ server.registerTool("update_project", {
295
+ description: "Update an existing project",
296
+ inputSchema: {
297
+ id: z.string().describe("Project ID"),
298
+ name: z.string().describe("Project name"),
299
+ color: z.string().optional().describe("Project color (hex)"),
300
+ directory: z.string().optional().describe("Local working directory"),
301
+ githubSource: z.string().optional().describe("GitHub repository in owner/repo format"),
302
+ },
303
+ }, async (args) => {
304
+ try {
305
+ const name = args.name;
306
+ if (!name?.trim())
307
+ return err("Project name is required");
308
+ projectManager.update(args.id, name.trim(), args.color, args.directory, args.githubSource);
309
+ return ok();
310
+ }
311
+ catch (e) {
312
+ return err(e instanceof Error ? e.message : String(e));
313
+ }
314
+ });
315
+ server.registerTool("delete_project", { description: "Delete a project by ID", inputSchema: { id: z.string().describe("Project ID") } }, async (args) => {
316
+ try {
317
+ projectManager.delete(args.id);
318
+ return ok();
319
+ }
320
+ catch (e) {
321
+ return err(e instanceof Error ? e.message : String(e));
322
+ }
323
+ });
324
+ server.registerTool("get_loop_logs", {
325
+ description: "Fetch loop log snapshot (last N lines)",
326
+ inputSchema: {
327
+ id: z.string().describe("Loop ID"),
328
+ tail: z.number().default(LOG_TAIL_DEFAULT).describe("Number of lines from the end"),
329
+ },
330
+ }, async (args) => {
331
+ const id = args.id;
332
+ const logPath = manager.getLogPath(id);
333
+ if (!logPath)
334
+ return err(`Loop not found: ${id}`);
335
+ if (!fs.existsSync(logPath))
336
+ return ok("");
337
+ const content = fs.readFileSync(logPath, "utf-8");
338
+ return ok(tail(content, args.tail ?? LOG_TAIL_DEFAULT).join("\n"));
339
+ });
340
+ server.registerTool("get_run_log", {
341
+ description: "Get run-specific log for a loop",
342
+ inputSchema: {
343
+ id: z.string().describe("Loop ID"),
344
+ runNumber: z.number().describe("Run number"),
345
+ },
346
+ }, async (args) => {
347
+ const id = args.id;
348
+ const runNumber = args.runNumber;
349
+ const logPath = manager.getLogPath(id);
350
+ if (!logPath || !fs.existsSync(logPath))
351
+ return ok("");
352
+ const meta = manager.status(id);
353
+ if (!meta)
354
+ return err(`Loop not found: ${id}`);
355
+ const records = meta.runHistory.filter((r) => r.runNumber === runNumber).sort((a, b) => a.logOffset - b.logOffset);
356
+ if (records.length === 0)
357
+ return ok("");
358
+ const buffer = fs.readFileSync(logPath);
359
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
360
+ const parts = records.map((record) => {
361
+ const start = record.logOffset;
362
+ const idx = allSorted.indexOf(record);
363
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
364
+ return buffer.toString("utf-8", start, end);
365
+ });
366
+ return ok(parts.join(""));
367
+ });
368
+ }
@@ -2,7 +2,8 @@ import { handleStart, handleUpdate, handleList, handleStatus, handlePause, handl
2
2
  import { handleRunLog, handleRunLogStream, handleLogs, } from "./log-handlers.js";
3
3
  import { handleTaskCreate, handleTaskUpdate, handleTaskList, handleTaskGet, handleTaskDelete, } from "./task-handlers.js";
4
4
  import { handleProjectList, handleProjectCreate, handleProjectUpdate, handleProjectDelete, } from "./project-handlers.js";
5
- export { handleStart, handleUpdate, handleList, handleStatus, handlePause, handleResume, handleStopLoop, handleStopAll, handlePlayLoop, handleTrigger, handleDelete, handleRunLog, handleRunLogStream, handleLogs, handleTaskCreate, handleTaskUpdate, handleTaskList, handleTaskGet, handleTaskDelete, handleProjectList, handleProjectCreate, handleProjectUpdate, handleProjectDelete, };
5
+ import { handleSettingsGet, handleSettingsSet, } from "./settings-handlers.js";
6
+ export { handleStart, handleUpdate, handleList, handleStatus, handlePause, handleResume, handleStopLoop, handleStopAll, handlePlayLoop, handleTrigger, handleDelete, handleRunLog, handleRunLogStream, handleLogs, handleTaskCreate, handleTaskUpdate, handleTaskList, handleTaskGet, handleTaskDelete, handleProjectList, handleProjectCreate, handleProjectUpdate, handleProjectDelete, handleSettingsGet, handleSettingsSet, };
6
7
  /**
7
8
  * Dispatch an incoming IPC request to the appropriate handler.
8
9
  * Returns true if the request was handled, false otherwise.
@@ -79,6 +80,12 @@ export async function dispatch(request, socket, ctx) {
79
80
  case "project-delete":
80
81
  handleProjectDelete(request, socket, ctx);
81
82
  break;
83
+ case "settings-get":
84
+ await handleSettingsGet(request, socket, ctx.settingsManager);
85
+ break;
86
+ case "settings-set":
87
+ await handleSettingsSet(request, socket, ctx.settingsManager);
88
+ break;
82
89
  default:
83
90
  return false;
84
91
  }
@@ -4,22 +4,27 @@ export function handleProjectList(_request, socket, ctx) {
4
4
  send(socket, { type: "ok", data: ctx.manager.listProjects() });
5
5
  }
6
6
  export function handleProjectCreate(request, socket, ctx) {
7
- const { name, color, directory } = request.payload;
7
+ const { name, color, directory, githubSource } = request.payload;
8
8
  if (!name || !name.trim()) {
9
9
  send(socket, { type: "error", message: t("project.error.nameRequired") });
10
10
  return;
11
11
  }
12
- const project = ctx.manager.createProject(name.trim(), color, directory);
13
- send(socket, { type: "ok", data: project });
12
+ try {
13
+ const project = ctx.manager.createProject(name.trim(), color, directory, githubSource);
14
+ send(socket, { type: "ok", data: project });
15
+ }
16
+ catch (err) {
17
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
18
+ }
14
19
  }
15
20
  export function handleProjectUpdate(request, socket, ctx) {
16
- const { id, name, color, directory } = request.payload;
21
+ const { id, name, color, directory, githubSource } = request.payload;
17
22
  if (!name || !name.trim()) {
18
23
  send(socket, { type: "error", message: t("project.error.nameEmpty") });
19
24
  return;
20
25
  }
21
26
  try {
22
- ctx.manager.updateProject(id, name.trim(), color, directory);
27
+ ctx.manager.updateProject(id, name.trim(), color, directory, githubSource);
23
28
  send(socket, { type: "ok" });
24
29
  }
25
30
  catch (err) {
@@ -0,0 +1,8 @@
1
+ import { send } from "../../ipc/send.js";
2
+ export async function handleSettingsGet(_request, socket, settingsManager) {
3
+ send(socket, { type: "ok", data: settingsManager.get() });
4
+ }
5
+ export async function handleSettingsSet(request, socket, settingsManager) {
6
+ const updated = settingsManager.set(request.settings);
7
+ send(socket, { type: "ok", data: updated });
8
+ }
@@ -4,11 +4,12 @@ import { t } from "../../shared/i18n/index.js";
4
4
  import { send } from "../ipc/send.js";
5
5
  import { dispatch } from "./handlers/index.js";
6
6
  export class IpcServer {
7
- constructor(manager, taskManager) {
7
+ constructor(manager, taskManager, settingsManager) {
8
8
  this.clients = new Set();
9
9
  this.subscribers = new Set();
10
10
  this.manager = manager;
11
11
  this.taskManager = taskManager;
12
+ this.settingsManager = settingsManager;
12
13
  this.socketPath = getSocketPath();
13
14
  this.server = net.createServer((socket) => this.handleConnection(socket));
14
15
  }
@@ -91,6 +92,7 @@ export class IpcServer {
91
92
  const handled = await dispatch(request, socket, {
92
93
  manager: this.manager,
93
94
  taskManager: this.taskManager,
95
+ settingsManager: this.settingsManager,
94
96
  respondOk: (s, ok, id, data) => this.respondOk(s, ok, id, data),
95
97
  });
96
98
  if (!handled) {
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs";
2
+ import { settingsJson } from "../shared/config/paths.js";
3
+ import { daemonLog } from "./daemon-log.js";
4
+ const DEFAULTS = { httpApiEnabled: true, mcpApiEnabled: true };
5
+ export class SettingsManager {
6
+ constructor() {
7
+ this.settings = { ...DEFAULTS };
8
+ this.listeners = [];
9
+ }
10
+ load() {
11
+ try {
12
+ const raw = fs.readFileSync(settingsJson(), "utf-8");
13
+ const parsed = JSON.parse(raw);
14
+ if (typeof parsed === "object" && parsed !== null) {
15
+ this.settings = { ...DEFAULTS, ...parsed };
16
+ }
17
+ }
18
+ catch {
19
+ this.settings = { ...DEFAULTS };
20
+ }
21
+ }
22
+ save() {
23
+ const tmp = settingsJson() + ".tmp";
24
+ fs.writeFileSync(tmp, JSON.stringify(this.settings, null, 2));
25
+ fs.renameSync(tmp, settingsJson());
26
+ }
27
+ get() {
28
+ return { ...this.settings };
29
+ }
30
+ set(partial) {
31
+ this.settings = { ...this.settings, ...partial };
32
+ this.save();
33
+ for (const cb of this.listeners) {
34
+ try {
35
+ cb(this.get());
36
+ }
37
+ catch (err) {
38
+ daemonLog(`settings onChange error: ${String(err)}`);
39
+ }
40
+ }
41
+ return this.get();
42
+ }
43
+ onChange(callback) {
44
+ this.listeners.push(callback);
45
+ }
46
+ }
@@ -23,7 +23,7 @@ function compare(left, right, sort) {
23
23
  return byCmd;
24
24
  return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
25
25
  }
26
- // "created"newest first
26
+ // "created" newest first
27
27
  return right.createdAt.localeCompare(left.createdAt);
28
28
  }
29
29
  export function applyTaskFilters(tasks, filters) {
@@ -4,7 +4,10 @@ function globalCommands() {
4
4
  return [
5
5
  { label: t('cmd.help'), value: 'help', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+p' },
6
6
  { label: t('cmd.debug'), value: 'debug', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+b' },
7
- { label: t('cmd.api'), value: 'api', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+g' },
7
+ { label: t('cmd.api'), value: 'api', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
8
+ { label: t('cmd.toggleApi'), value: 'toggle-api', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
9
+ { label: t('cmd.mcp'), value: 'mcp', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
10
+ { label: t('cmd.toggleMcp'), value: 'toggle-mcp', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
8
11
  { label: t('cmd.export'), value: 'export', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+x' },
9
12
  { label: t('cmd.import'), value: 'import', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+i' },
10
13
  { label: t('cmd.status'), value: 'status', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+y' },
@@ -2,6 +2,8 @@ import { t } from "../../shared/i18n/index.js";
2
2
  import { cycleSortMode, cycleStatusFilter } from "../../entities/loops/filters.js";
3
3
  import { cycleProjectSortMode, cycleProjectHasLoopsFilter, cycleProjectIsSystemFilter } from "../../entities/projects/filters.js";
4
4
  import { groupRunsByCycle } from "../../widgets/right-panel/RunHistory.js";
5
+ import { container } from "../../shared/container/index.js";
6
+ import { TYPES } from "../../shared/services/types.js";
5
7
  export function useCommandHandlers(context) {
6
8
  const { activeTab, selected, selectedRunIndex, selectedTask, selectedProjectEntity, tasks, projects, currentProjectId, setCloneMode, setEditTarget, setPendingTaskSelection, setEditTask, setEditProject, setActiveTab, setConfirmState, setCommandsBrowserOpen, setSearchValue, setSearchState, setFilters, setSort, setCurrentProjectId, setProjectFilters, setProjectSelectedIndex, setDebugMode, setExportModal, push, pop, refresh, refreshTasks, refreshProjects, pushToast, loopService, taskService, projectService, exportService, runAction, handleOpenRunLog, } = context;
7
9
  const commandHandlers = {
@@ -128,6 +130,36 @@ export function useCommandHandlers(context) {
128
130
  const baseUrl = `http://127.0.0.1:${port}`;
129
131
  pushToast("info", `API: ${baseUrl} | Swagger: ${baseUrl}/api/docs | OpenAPI: ${baseUrl}/api/openapi.json`);
130
132
  },
133
+ "toggle-api": async () => {
134
+ try {
135
+ const settingsService = container.get(TYPES.SettingsService);
136
+ const current = await settingsService.getHttpApiEnabled();
137
+ await settingsService.setHttpApiEnabled(!current);
138
+ pushToast("success", t(!current ? "board.toastApiEnabled" : "board.toastApiDisabled"));
139
+ }
140
+ catch (e) {
141
+ pushToast("error", t("board.toastApiToggleError", { message: e.message }));
142
+ }
143
+ },
144
+ mcp: () => {
145
+ const transport = process.env.LOOP_CLI_MCP_TRANSPORT ?? "sse";
146
+ const port = process.env.LOOP_CLI_MCP_PORT ?? "8846";
147
+ const info = transport === "stdio"
148
+ ? `MCP server on stdio. To use SSE (default), restart without LOOP_CLI_MCP_TRANSPORT=stdio`
149
+ : `MCP: connect your client to http://127.0.0.1:${port}/sse`;
150
+ pushToast("info", info);
151
+ },
152
+ "toggle-mcp": async () => {
153
+ try {
154
+ const settingsService = container.get(TYPES.SettingsService);
155
+ const current = await settingsService.getMcpApiEnabled();
156
+ await settingsService.setMcpApiEnabled(!current);
157
+ pushToast("success", t(!current ? "board.toastMcpEnabled" : "board.toastMcpDisabled"));
158
+ }
159
+ catch (e) {
160
+ pushToast("error", t("board.toastMcpToggleError", { message: e.message }));
161
+ }
162
+ },
131
163
  status: () => { pushToast("info", `Command "status" coming soon`); },
132
164
  export: () => {
133
165
  exportService.exportConfig()
@@ -46,7 +46,6 @@ export function useGlobalShortcuts(context) {
46
46
  const globalShortcuts = {
47
47
  p: () => { setCommandsBrowserOpen(true); },
48
48
  b: () => handleCommand("debug"),
49
- g: () => handleCommand("api"),
50
49
  x: () => handleCommand("export"),
51
50
  i: () => handleCommand("import"),
52
51
  y: () => handleCommand("status"),
@@ -15,7 +15,7 @@ export function ExportModal(props) {
15
15
  const [scrollOffset, setScrollOffset] = useState(0);
16
16
  useInput((input, key) => {
17
17
  // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
18
- // before the escape checkthe leading ESC trips key.escape and would
18
+ // before the escape check the leading ESC trips key.escape and would
19
19
  // close the modal on a right-click paste.
20
20
  if (input.includes("\x1b[200~")) {
21
21
  return;
@@ -96,7 +96,7 @@ export function joinCommandLines(text) {
96
96
  // - Segment ending with \: backslash consumed, joins to next with NO added space
97
97
  // - Segment NOT ending with \: joins to next with a single space
98
98
  // Leading whitespace on each line is trimmed (indentation); trailing whitespace
99
- // before \ is preserved (it's part of the contente.g. separates tokens).
99
+ // before \ is preserved (it's part of the content e.g. separates tokens).
100
100
  let result = "";
101
101
  let prevContinued = false; // true if previous segment ended with \
102
102
  for (const seg of segments) {
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { platform } from "node:os";
3
3
  /**
4
- * Write text to the system clipboard. Never throwsreturns false if no
4
+ * Write text to the system clipboard. Never throws returns false if no
5
5
  * clipboard tool is available (e.g. a headless SSH box without xclip/xsel).
6
6
  *
7
7
  * Linux toolchain tried in order: xclip → xsel → wl-copy (Wayland) →
@@ -28,7 +28,7 @@ export function copyToClipboard(text) {
28
28
  return false;
29
29
  }
30
30
  }
31
- // Linux / BSD: try each clipboard tool in order. All calls are guarded
31
+ // Linux / BSD: try each clipboard tool in order. All calls are guarded
32
32
  // a missing binary must never throw, since callers invoke this from
33
33
  // synchronous keypress handlers (an uncaught throw here kills the app).
34
34
  const tries = [
@@ -30,6 +30,9 @@ export function tasksJson() {
30
30
  export function projectsJson() {
31
31
  return path.join(getDataDir(), "projects.json");
32
32
  }
33
+ export function settingsJson() {
34
+ return path.join(getDataDir(), "settings.json");
35
+ }
33
36
  export function logFile(id) {
34
37
  return path.join(getLogsDir(), `${id}.log`);
35
38
  }
@@ -5,12 +5,14 @@ import { IpcTaskService } from "../services/task-service.js";
5
5
  import { IpcProjectService } from "../services/project-service.js";
6
6
  import { IpcLogService } from "../services/log-service.js";
7
7
  import { IpcExportService } from "../services/export-service.js";
8
+ import { IpcSettingsService } from "../services/settings-service.js";
8
9
  export function createContainer() {
9
10
  const container = new Container();
10
11
  container.bind(TYPES.LoopService).to(IpcLoopService);
11
12
  container.bind(TYPES.TaskService).to(IpcTaskService);
12
13
  container.bind(TYPES.ProjectService).to(IpcProjectService);
13
14
  container.bind(TYPES.LogService).to(IpcLogService);
15
+ container.bind(TYPES.SettingsService).to(IpcSettingsService);
14
16
  container.bind(TYPES.ExportService).toDynamicValue(() => {
15
17
  const loopService = container.get(TYPES.LoopService);
16
18
  const taskService = container.get(TYPES.TaskService);