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
@@ -0,0 +1,162 @@
1
+ import http from "node:http";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import { daemonLog } from "../daemon-log.js";
6
+ import { registerMcpTools } from "./tools.js";
7
+ const MCP_DEFAULT_PORT = 8846;
8
+ export class McpApiServer {
9
+ constructor(manager, taskManager, projectManager, transport) {
10
+ this.manager = manager;
11
+ this.taskManager = taskManager;
12
+ this.projectManager = projectManager;
13
+ this.mcpServer = null;
14
+ this.activeTransport = null;
15
+ this.httpServer = null;
16
+ this._isListening = false;
17
+ this.transportType = transport ?? "sse";
18
+ }
19
+ createServer() {
20
+ const server = new McpServer({ name: "loop-task", version: "1.0.0" }, { capabilities: { tools: {} } });
21
+ registerMcpTools(server, { manager: this.manager, taskManager: this.taskManager, projectManager: this.projectManager });
22
+ return server;
23
+ }
24
+ get isListening() {
25
+ return this._isListening;
26
+ }
27
+ async start() {
28
+ if (this._isListening)
29
+ return;
30
+ try {
31
+ if (this.transportType === "stdio") {
32
+ await this.startStdio();
33
+ }
34
+ else {
35
+ await this.startSse();
36
+ }
37
+ }
38
+ catch (err) {
39
+ daemonLog(`MCP server failed to start: ${String(err)}`);
40
+ }
41
+ }
42
+ async close() {
43
+ if (!this._isListening)
44
+ return;
45
+ this._isListening = false;
46
+ if (this.mcpServer) {
47
+ try {
48
+ await this.mcpServer.close();
49
+ }
50
+ catch {
51
+ // ignore close errors
52
+ }
53
+ this.mcpServer = null;
54
+ }
55
+ if (this.httpServer) {
56
+ await new Promise((resolve) => {
57
+ this.httpServer.close(() => resolve());
58
+ this.httpServer.closeAllConnections?.();
59
+ });
60
+ this.httpServer = null;
61
+ }
62
+ this.activeTransport = null;
63
+ daemonLog("MCP server stopped");
64
+ }
65
+ async startStdio() {
66
+ if (!process.stdin || !process.stdout) {
67
+ daemonLog("MCP stdio transport: stdin/stdout not available, skipping");
68
+ return;
69
+ }
70
+ const server = this.createServer();
71
+ const transport = new StdioServerTransport();
72
+ this.mcpServer = server;
73
+ this.activeTransport = transport;
74
+ await server.connect(transport);
75
+ this._isListening = true;
76
+ daemonLog("MCP server listening on stdio");
77
+ transport.onclose = () => {
78
+ this._isListening = false;
79
+ daemonLog("MCP stdio transport closed");
80
+ };
81
+ }
82
+ async startSse() {
83
+ const port = parseInt(process.env.LOOP_CLI_MCP_PORT ?? "", 10) || MCP_DEFAULT_PORT;
84
+ // Map session ID → transport for POST routing
85
+ const sessions = new Map();
86
+ this.httpServer = http.createServer(async (req, res) => {
87
+ try {
88
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
89
+ if (req.method === "GET" && url.pathname === "/sse") {
90
+ // Create a fresh McpServer per SSE connection — the SDK only allows
91
+ // one transport per McpServer instance, so reusing one would cause
92
+ // "Already connected to a transport" errors on reconnect.
93
+ const server = this.createServer();
94
+ const transport = new SSEServerTransport("/message", res);
95
+ sessions.set(transport.sessionId, transport);
96
+ await server.connect(transport);
97
+ if (!this.mcpServer)
98
+ this.mcpServer = server;
99
+ this._isListening = true;
100
+ daemonLog(`MCP SSE client connected (session: ${transport.sessionId})`);
101
+ transport.onclose = () => {
102
+ sessions.delete(transport.sessionId);
103
+ daemonLog(`MCP SSE client disconnected (session: ${transport.sessionId})`);
104
+ };
105
+ }
106
+ else if (req.method === "POST" && url.pathname === "/message") {
107
+ const body = await this.readBody(req);
108
+ // Route the POST to the correct transport by session ID
109
+ const sessionId = url.searchParams.get("sessionId");
110
+ const transport = sessionId ? sessions.get(sessionId) : sessions.values().next().value;
111
+ if (transport) {
112
+ await transport.handlePostMessage(req, res, body);
113
+ }
114
+ else {
115
+ res.writeHead(400).end("No active SSE connection");
116
+ }
117
+ }
118
+ else {
119
+ res.writeHead(404).end("Not found");
120
+ }
121
+ }
122
+ catch (err) {
123
+ daemonLog(`MCP SSE request error: ${String(err)}`);
124
+ if (!res.headersSent) {
125
+ res.writeHead(500).end("Internal error");
126
+ }
127
+ }
128
+ });
129
+ return new Promise((resolve) => {
130
+ this.httpServer.on("error", (err) => {
131
+ if (err.code === "EADDRINUSE") {
132
+ daemonLog(`MCP SSE server: port ${port} already in use, skipping SSE transport`);
133
+ }
134
+ else {
135
+ daemonLog(`MCP SSE server error: ${String(err)}`);
136
+ }
137
+ resolve();
138
+ });
139
+ this.httpServer.listen(port, "127.0.0.1", () => {
140
+ daemonLog(`MCP SSE server listening on 127.0.0.1:${port}`);
141
+ this._isListening = true;
142
+ resolve();
143
+ });
144
+ });
145
+ }
146
+ readBody(req) {
147
+ return new Promise((resolve, reject) => {
148
+ const chunks = [];
149
+ req.on("data", (chunk) => chunks.push(chunk));
150
+ req.on("end", () => {
151
+ try {
152
+ const raw = Buffer.concat(chunks).toString("utf-8");
153
+ resolve(raw ? JSON.parse(raw) : {});
154
+ }
155
+ catch {
156
+ resolve({});
157
+ }
158
+ });
159
+ req.on("error", reject);
160
+ });
161
+ }
162
+ }
@@ -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
+ }