clawmatrix 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.ts CHANGED
@@ -10,6 +10,9 @@ import type { KnowledgeSync } from "./knowledge-sync.ts";
10
10
  import { nanoid } from "nanoid";
11
11
  import { timingSafeEqual } from "./auth.ts";
12
12
  import { readBody } from "./http-utils.ts";
13
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { dirname } from "node:path";
13
16
 
14
17
  const COOKIE_NAME = "clawmatrix_token";
15
18
  const SESSION_MAX_AGE = 86400 * 7; // 7 days
@@ -27,6 +30,255 @@ const CLUSTER_TOOLS = [
27
30
  "cluster_notify", "cluster_query",
28
31
  ];
29
32
 
33
+ /** Full tool definitions for /api/tools endpoint. */
34
+ const CLUSTER_TOOL_DEFS: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }> = [
35
+ {
36
+ name: "cluster_peers",
37
+ description: "List all peers in the cluster with their agents, models, tools, tags, and connection status. Call this first to discover what's available before using other cluster tools.",
38
+ inputSchema: { type: "object", properties: {} },
39
+ },
40
+ {
41
+ name: "cluster_exec",
42
+ description: "Execute a shell command on a remote cluster node. Returns {stdout, stderr, exitCode}. Use nodeId or 'tags:<tag>' to specify the target.",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {
46
+ node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
47
+ command: { type: "string", description: "Shell command to execute" },
48
+ workdir: { type: "string", description: "Working directory (optional)" },
49
+ timeout: { type: "number", description: "Timeout in seconds (default 1800)" },
50
+ },
51
+ required: ["node", "command"],
52
+ },
53
+ },
54
+ {
55
+ name: "cluster_read",
56
+ description: "Read a file from a remote cluster node. Returns {content} with the file text.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
61
+ path: { type: "string", description: "File path to read" },
62
+ },
63
+ required: ["node", "path"],
64
+ },
65
+ },
66
+ {
67
+ name: "cluster_write",
68
+ description: "Write content to a file on a remote cluster node (creates or overwrites).",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
73
+ path: { type: "string", description: "File path to write" },
74
+ content: { type: "string", description: "Content to write" },
75
+ },
76
+ required: ["node", "path", "content"],
77
+ },
78
+ },
79
+ {
80
+ name: "cluster_edit",
81
+ description: "Edit a file on a remote cluster node by replacing exact text. Use cluster_read first to get exact file content.",
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
86
+ path: { type: "string", description: "File path to edit" },
87
+ oldText: { type: "string", description: "Exact text to find and replace" },
88
+ newText: { type: "string", description: "New text to replace with" },
89
+ },
90
+ required: ["node", "path", "oldText", "newText"],
91
+ },
92
+ },
93
+ {
94
+ name: "cluster_handoff",
95
+ description: "Hand off a complex task to another agent in the cluster and wait for the result. Use for multi-step tasks that require the remote agent's full capabilities.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ target: { type: "string", description: "Agent ID or \"tags:<tag>\" expression" },
100
+ task: { type: "string", description: "Task description for the remote agent" },
101
+ context: { type: "string", description: "Additional context for the task" },
102
+ },
103
+ required: ["target", "task"],
104
+ },
105
+ },
106
+ {
107
+ name: "cluster_handoff_reply",
108
+ description: "Reply to a remote agent that requested more input during a handoff. Use the handoff_id returned by cluster_handoff.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ handoff_id: { type: "string", description: "The handoff ID from cluster_handoff's response" },
113
+ message: { type: "string", description: "Your reply to the remote agent's question" },
114
+ },
115
+ required: ["handoff_id", "message"],
116
+ },
117
+ },
118
+ {
119
+ name: "cluster_send",
120
+ description: "Send a one-way notification to a remote agent. Fire-and-forget: does not wait for a response.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {
124
+ target: { type: "string", description: "Agent ID or \"tags:<tag>\" expression" },
125
+ message: { type: "string", description: "Message content to send" },
126
+ },
127
+ required: ["target", "message"],
128
+ },
129
+ },
130
+ {
131
+ name: "cluster_batch",
132
+ description: "Execute multiple tools on a remote node in a single round-trip. Tools run sequentially (supports dependencies like read→edit).",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {
136
+ node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
137
+ items: { type: "array", description: "Array of { tool, params } to execute in order", items: { type: "object", properties: { tool: { type: "string" }, params: { type: "object" } }, required: ["tool", "params"] } },
138
+ stopOnError: { type: "boolean", description: "Stop on first error (default true)" },
139
+ },
140
+ required: ["node", "items"],
141
+ },
142
+ },
143
+ {
144
+ name: "cluster_tool",
145
+ description: "Invoke any tool on a remote cluster node by name. Preferred for device-specific tools (screenshot, battery, location, clipboard, etc.).",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ node: { type: "string", description: "Target nodeId or \"tags:<tag>\"" },
150
+ tool: { type: "string", description: "Remote tool name to invoke" },
151
+ params: { type: "object", description: "Parameters to pass to the remote tool" },
152
+ timeout: { type: "number", description: "Timeout in seconds (default 30)" },
153
+ },
154
+ required: ["node", "tool"],
155
+ },
156
+ },
157
+ {
158
+ name: "cluster_terminal",
159
+ description: "Interactive terminal (PTY) on a remote cluster node. Actions: open, input, read, resize, list, close. Prefer cluster_exec for one-shot commands.",
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ action: { type: "string", enum: ["open", "input", "read", "resize", "list", "close"], description: "Action to perform (default: open)" },
164
+ node: { type: "string", description: "Target node ID (required for open)" },
165
+ sessionId: { type: "string", description: "Session ID (required for input/read/resize/close)" },
166
+ data: { type: "string", description: "Input data to send (for input action)" },
167
+ shell: { type: "string", description: "Shell to use (default: $SHELL)" },
168
+ cols: { type: "number", description: "Terminal columns (default: 80)" },
169
+ rows: { type: "number", description: "Terminal rows (default: 24)" },
170
+ },
171
+ },
172
+ },
173
+ {
174
+ name: "cluster_transfer",
175
+ description: "Transfer a file between local and remote nodes. Supports up to 100MB with chunked transfer and SHA-256 integrity check.",
176
+ inputSchema: {
177
+ type: "object",
178
+ properties: {
179
+ source_path: { type: "string", description: "File path on the source node" },
180
+ target_path: { type: "string", description: "File path on the target node" },
181
+ source_node: { type: "string", description: "Source nodeId (omit for local)" },
182
+ target_node: { type: "string", description: "Target nodeId (omit for local)" },
183
+ },
184
+ required: ["source_path", "target_path"],
185
+ },
186
+ },
187
+ {
188
+ name: "cluster_events",
189
+ description: "Query and consume events from external sources (iOS Shortcuts, webhooks, etc.). Events are persistent until consumed.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ action: { type: "string", enum: ["query", "consume"], description: "\"query\" to list events, \"consume\" to mark as processed" },
194
+ type: { type: "string", description: "Filter by event type" },
195
+ source: { type: "string", description: "Filter by source" },
196
+ unconsumed: { type: "boolean", description: "Only unconsumed events (default true)" },
197
+ limit: { type: "number", description: "Max events to return (default 20)" },
198
+ ids: { type: "array", items: { type: "string" }, description: "Event IDs to consume" },
199
+ },
200
+ required: ["action"],
201
+ },
202
+ },
203
+ {
204
+ name: "cluster_diagnostic",
205
+ description: "Diagnose or execute commands on a remote node's sentinel process. Works even when the gateway is down.",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ node: { type: "string", description: "Target nodeId" },
210
+ action: { type: "string", enum: ["status", "exec"], description: "\"status\" to check health, \"exec\" to run a command" },
211
+ command: { type: "string", description: "Shell command (required for exec)" },
212
+ timeout: { type: "number", description: "Timeout in seconds (default 30)" },
213
+ },
214
+ required: ["node", "action"],
215
+ },
216
+ },
217
+ {
218
+ name: "cluster_acp",
219
+ description: "Run a task on a remote coding agent (Claude Code, Codex, Gemini CLI) via ACP protocol. Supports one-shot and persistent sessions.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ action: { type: "string", enum: ["prompt", "list", "resume", "cancel", "set_mode", "get_modes", "close"], description: "Action to perform (default: prompt)" },
224
+ node: { type: "string", description: "Target node ID or \"tags:<tag>\"" },
225
+ agent: { type: "string", description: "ACP agent name: \"claude\", \"codex\", \"gemini\"" },
226
+ task: { type: "string", description: "Task or prompt to send" },
227
+ sessionId: { type: "string", description: "Session ID for follow-up or management" },
228
+ mode: { type: "string", enum: ["oneshot", "persistent"], description: "Session mode (default: oneshot)" },
229
+ cwd: { type: "string", description: "Working directory on the remote node" },
230
+ },
231
+ required: ["node"],
232
+ },
233
+ },
234
+ {
235
+ name: "cluster_kanban",
236
+ description: "Manage the distributed kanban board for tracking work items across the cluster. Supports create, list, get, claim, move, annotate, update, delete, summary.",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ action: { type: "string", enum: ["create", "list", "get", "claim", "move", "annotate", "update", "delete", "summary"], description: "Action to perform (default: list)" },
241
+ cardId: { type: "string", description: "Card ID for get/claim/move/annotate/update/delete" },
242
+ title: { type: "string", description: "Card title (for create or update)" },
243
+ description: { type: "string", description: "Card description in Markdown" },
244
+ stage: { type: "string", enum: ["backlog", "claimed", "in_progress", "review", "done", "archived"], description: "Target stage or filter" },
245
+ priority: { type: "string", enum: ["low", "medium", "high", "urgent"], description: "Card priority" },
246
+ labels: { type: "array", items: { type: "string" }, description: "Labels" },
247
+ },
248
+ },
249
+ },
250
+ {
251
+ name: "cluster_notify",
252
+ description: "Push a notification to mobile devices in the cluster (Dynamic Island / Live Activity on iOS). Track long-running task progress.",
253
+ inputSchema: {
254
+ type: "object",
255
+ properties: {
256
+ action: { type: "string", enum: ["start", "update", "end"], description: "Action to perform (default: start)" },
257
+ taskId: { type: "string", description: "Task ID for update/end actions" },
258
+ title: { type: "string", description: "Activity title" },
259
+ detail: { type: "string", description: "Activity detail text" },
260
+ progress: { type: "number", description: "Progress 0.0 to 1.0" },
261
+ tool: { type: "string", description: "Current tool being executed" },
262
+ success: { type: "boolean", description: "For end: true=completed, false=failed" },
263
+ },
264
+ },
265
+ },
266
+ {
267
+ name: "cluster_query",
268
+ description: "Query replicated data from SQLite: audit_log, health_events, handoff_history. Data is replicated across peers for cluster-wide view.",
269
+ inputSchema: {
270
+ type: "object",
271
+ properties: {
272
+ table: { type: "string", enum: ["audit_log", "health_events", "handoff_history"], description: "Which table to query" },
273
+ since: { type: "number", description: "Events after this unix timestamp (ms). Negative for relative." },
274
+ node_id: { type: "string", description: "Filter by node ID" },
275
+ limit: { type: "number", description: "Max rows (default 50)" },
276
+ },
277
+ required: ["table"],
278
+ },
279
+ },
280
+ ];
281
+
30
282
  interface SatelliteEvent {
31
283
  ts: number;
32
284
  type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested" | "kanban";
@@ -216,6 +468,11 @@ export class ApiHandler {
216
468
  return;
217
469
  }
218
470
 
471
+ if (path === "/api/tools" && req.method === "GET") {
472
+ this.handleToolsList(res);
473
+ return;
474
+ }
475
+
219
476
  if (path === "/api/chat" && req.method === "POST") {
220
477
  this.handleChat(req, res);
221
478
  return;
@@ -267,6 +524,16 @@ export class ApiHandler {
267
524
  return;
268
525
  }
269
526
 
527
+ if (path === "/api/config/read" && req.method === "GET") {
528
+ this.handleConfigRead(req, res);
529
+ return;
530
+ }
531
+
532
+ if (path === "/api/config/write" && req.method === "POST") {
533
+ this.handleConfigWrite(req, res);
534
+ return;
535
+ }
536
+
270
537
  if (path === "/api/availability" && req.method === "GET") {
271
538
  this.handleAvailability(req, res);
272
539
  return;
@@ -298,6 +565,11 @@ export class ApiHandler {
298
565
  return;
299
566
  }
300
567
 
568
+ if (path === "/api/knowledge/content" && req.method === "GET") {
569
+ this.handleKnowledgeContent(req, res);
570
+ return;
571
+ }
572
+
301
573
  // Board / Kanban API
302
574
  if (path === "/api/board" && req.method === "GET") {
303
575
  this.handleBoardSummary(res);
@@ -434,9 +706,138 @@ export class ApiHandler {
434
706
  return;
435
707
  }
436
708
 
437
- const result = this.healthTracker.getAvailability(range);
709
+ try {
710
+ const result = this.healthTracker.getAvailability(range);
711
+ res.writeHead(200, { "Content-Type": "application/json" });
712
+ res.end(JSON.stringify(result));
713
+ } catch (err) {
714
+ res.writeHead(500, { "Content-Type": "application/json" });
715
+ res.end(JSON.stringify({ error: String((err as Error)?.message ?? err) }));
716
+ }
717
+ }
718
+
719
+ // ── Config file API (local node) ────────────────────────────────
720
+
721
+ /** Resolve config path, restricted to ~/.openclaw/ for safety. */
722
+ private resolveConfigPath(configPath: string): string | null {
723
+ const home = homedir();
724
+ const openclawDir = `${home}/.openclaw`;
725
+ // Normalize: replace ~ with home dir
726
+ const resolved = configPath.startsWith("~")
727
+ ? configPath.replace(/^~/, home)
728
+ : configPath;
729
+ // Security: only allow files under ~/.openclaw/
730
+ if (!resolved.startsWith(openclawDir)) return null;
731
+ // Prevent path traversal
732
+ if (resolved.includes("..")) return null;
733
+ return resolved;
734
+ }
735
+
736
+ /** Read a config file (for gateway methods). */
737
+ async readConfigFile(configPath: string): Promise<{ success: boolean; content?: string; error?: string }> {
738
+ const resolved = this.resolveConfigPath(configPath);
739
+ if (!resolved) return { success: false, error: "Path must be under ~/.openclaw/" };
740
+ try {
741
+ const content = await readFile(resolved, "utf-8");
742
+ return { success: true, content };
743
+ } catch (err: unknown) {
744
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { success: true, content: "{}" };
745
+ return { success: false, error: err instanceof Error ? err.message : "Failed to read config" };
746
+ }
747
+ }
748
+
749
+ /** Write a config file (for gateway methods). */
750
+ async writeConfigFile(configPath: string, content: string): Promise<{ success: boolean; error?: string }> {
751
+ const resolved = this.resolveConfigPath(configPath);
752
+ if (!resolved) return { success: false, error: "Path must be under ~/.openclaw/" };
753
+ try {
754
+ await mkdir(dirname(resolved), { recursive: true });
755
+ await writeFile(resolved, content, "utf-8");
756
+ return { success: true };
757
+ } catch (err) {
758
+ return { success: false, error: err instanceof Error ? err.message : "Failed to write config" };
759
+ }
760
+ }
761
+
762
+ /** GET /api/config/read?path=<configPath> — read a local config file. */
763
+ private async handleConfigRead(req: IncomingMessage, res: ServerResponse) {
764
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
765
+ const configPath = url.searchParams.get("path");
766
+ if (!configPath) {
767
+ res.writeHead(400, { "Content-Type": "application/json" });
768
+ res.end(JSON.stringify({ error: "Missing required query param: path" }));
769
+ return;
770
+ }
771
+
772
+ const resolved = this.resolveConfigPath(configPath);
773
+ if (!resolved) {
774
+ res.writeHead(403, { "Content-Type": "application/json" });
775
+ res.end(JSON.stringify({ error: "Path must be under ~/.openclaw/" }));
776
+ return;
777
+ }
778
+
779
+ try {
780
+ const content = await readFile(resolved, "utf-8");
781
+ res.writeHead(200, { "Content-Type": "application/json" });
782
+ res.end(JSON.stringify({ success: true, content }));
783
+ } catch (err: unknown) {
784
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
785
+ res.writeHead(200, { "Content-Type": "application/json" });
786
+ res.end(JSON.stringify({ success: true, content: "{}" }));
787
+ } else {
788
+ res.writeHead(500, { "Content-Type": "application/json" });
789
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to read config" }));
790
+ }
791
+ }
792
+ }
793
+
794
+ /** POST /api/config/write — write a local config file. Body: { path, content } */
795
+ private async handleConfigWrite(req: IncomingMessage, res: ServerResponse) {
796
+ try {
797
+ const body = await readBody(req);
798
+ const { path: configPath, content } = JSON.parse(body);
799
+ if (!configPath || typeof content !== "string") {
800
+ res.writeHead(400, { "Content-Type": "application/json" });
801
+ res.end(JSON.stringify({ error: "path and content required" }));
802
+ return;
803
+ }
804
+
805
+ const resolved = this.resolveConfigPath(configPath);
806
+ if (!resolved) {
807
+ res.writeHead(403, { "Content-Type": "application/json" });
808
+ res.end(JSON.stringify({ error: "Path must be under ~/.openclaw/" }));
809
+ return;
810
+ }
811
+
812
+ // Ensure directory exists
813
+ await mkdir(dirname(resolved), { recursive: true });
814
+ await writeFile(resolved, content, "utf-8");
815
+ res.writeHead(200, { "Content-Type": "application/json" });
816
+ res.end(JSON.stringify({ success: true }));
817
+ } catch (err) {
818
+ res.writeHead(500, { "Content-Type": "application/json" });
819
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to write config" }));
820
+ }
821
+ }
822
+
823
+ /** GET /api/tools — list cluster tools with descriptions and input schemas. */
824
+ private handleToolsList(res: ServerResponse) {
825
+ // Build per-node tool info: local node tools + remote peer tools
826
+ const nodes: Array<{ nodeId: string; tools: typeof CLUSTER_TOOL_DEFS }> = [];
827
+
828
+ // Local node
829
+ nodes.push({ nodeId: this.config.nodeId, tools: CLUSTER_TOOL_DEFS });
830
+
831
+ // Remote peers — include cluster tools for each online peer with toolProxy
832
+ const peers = this.peerManager.router.getAllPeers();
833
+ for (const p of peers) {
834
+ if (!this.peerManager.canReach(p.nodeId)) continue;
835
+ if (!p.toolProxy?.enabled) continue;
836
+ nodes.push({ nodeId: p.nodeId, tools: CLUSTER_TOOL_DEFS });
837
+ }
838
+
438
839
  res.writeHead(200, { "Content-Type": "application/json" });
439
- res.end(JSON.stringify(result));
840
+ res.end(JSON.stringify({ nodes }));
440
841
  }
441
842
 
442
843
  private handleStatus(res: ServerResponse) {
@@ -1208,6 +1609,46 @@ export class ApiHandler {
1208
1609
  return events.slice(-(opts?.limit ?? 50)).reverse();
1209
1610
  }
1210
1611
 
1612
+ /** Ingest events programmatically (from gateway methods, CLI, etc.). */
1613
+ ingestEvents(items: Array<Record<string, unknown>>): { count: number; ids: string[] } {
1614
+ const ingested: IngestedEvent[] = [];
1615
+ for (const item of items) {
1616
+ if (!item.type) continue;
1617
+ const event: IngestedEvent = {
1618
+ id: nanoid(),
1619
+ source: String(item.source || "unknown"),
1620
+ type: String(item.type),
1621
+ data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
1622
+ ts: typeof item.ts === "number" ? Math.min(item.ts as number, Date.now()) : Date.now(),
1623
+ consumed: false,
1624
+ };
1625
+ this.ingestedEvents.push(event);
1626
+ ingested.push(event);
1627
+ try {
1628
+ this.store?.insertIngestedEvent({
1629
+ id: event.id, ts: event.ts, type: event.type, source: event.source,
1630
+ data: JSON.stringify(event.data),
1631
+ });
1632
+ } catch { /* non-fatal */ }
1633
+ }
1634
+ if (this.ingestedEvents.length > MAX_INGESTED_EVENTS) {
1635
+ this.ingestedEvents.splice(0, this.ingestedEvents.length - MAX_INGESTED_EVENTS);
1636
+ }
1637
+ for (const event of ingested) {
1638
+ this.pushSatelliteEvent({
1639
+ ts: event.ts,
1640
+ type: "event_ingested" as SatelliteEvent["type"],
1641
+ data: { id: event.id, source: event.source, type: event.type },
1642
+ });
1643
+ }
1644
+ if (this.automationManager) {
1645
+ for (const event of ingested) {
1646
+ this.automationManager.onEventIngested(event);
1647
+ }
1648
+ }
1649
+ return { count: ingested.length, ids: ingested.map(e => e.id) };
1650
+ }
1651
+
1211
1652
  /** Mark events as consumed by id(s). */
1212
1653
  consumeEvents(ids: string[]): number {
1213
1654
  if (this.store) {
@@ -1251,8 +1692,9 @@ export class ApiHandler {
1251
1692
  return;
1252
1693
  }
1253
1694
  const files = this.knowledgeSync.listSyncedFiles();
1695
+ const workspacePath = this.knowledgeSync.workspacePath;
1254
1696
  res.writeHead(200, { "Content-Type": "application/json" });
1255
- res.end(JSON.stringify({ success: true, files }));
1697
+ res.end(JSON.stringify({ success: true, files, workspacePath }));
1256
1698
  }
1257
1699
 
1258
1700
  /** GET /api/knowledge/history?path=<relPath> — file change history from CRDT. */
@@ -1292,6 +1734,37 @@ export class ApiHandler {
1292
1734
  res.end(JSON.stringify({ success: true, path: filePath, history }));
1293
1735
  }
1294
1736
 
1737
+ /** GET /api/knowledge/content?path=<relPath> — read synced file content from CRDT. */
1738
+ private handleKnowledgeContent(req: IncomingMessage, res: ServerResponse) {
1739
+ if (!this.knowledgeSync) {
1740
+ res.writeHead(503, { "Content-Type": "application/json" });
1741
+ res.end(JSON.stringify({ error: "Knowledge sync not enabled" }));
1742
+ return;
1743
+ }
1744
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1745
+ const filePath = url.searchParams.get("path");
1746
+ if (!filePath) {
1747
+ res.writeHead(400, { "Content-Type": "application/json" });
1748
+ res.end(JSON.stringify({ error: "Missing required query param: path" }));
1749
+ return;
1750
+ }
1751
+ const content = this.knowledgeSync.getFileContent(filePath);
1752
+ if (content === null) {
1753
+ // Distinguish between "not in registry" and "in registry but content not yet synced"
1754
+ const listed = this.knowledgeSync.listSyncedFiles().find(f => f.path === filePath);
1755
+ if (listed) {
1756
+ res.writeHead(202, { "Content-Type": "application/json" });
1757
+ res.end(JSON.stringify({ error: "File content not yet synced", syncing: true }));
1758
+ } else {
1759
+ res.writeHead(404, { "Content-Type": "application/json" });
1760
+ res.end(JSON.stringify({ error: "File not found" }));
1761
+ }
1762
+ return;
1763
+ }
1764
+ res.writeHead(200, { "Content-Type": "application/json" });
1765
+ res.end(JSON.stringify({ success: true, path: filePath, content }));
1766
+ }
1767
+
1295
1768
  // ── Board / Kanban API handlers ─────────────────────────────────
1296
1769
 
1297
1770
  private handleBoardSummary(res: ServerResponse) {
package/src/automation.ts CHANGED
@@ -27,7 +27,14 @@ export interface AutomationTrigger {
27
27
  type: "event" | "schedule";
28
28
  source?: string;
29
29
  eventType?: string;
30
+ /** Interval-based schedule: repeat every N milliseconds (min 10s). */
30
31
  intervalMs?: number;
32
+ /** Daily schedule: time of day in "HH:MM" (24h) format. */
33
+ dailyTime?: string;
34
+ /** IANA timezone for daily schedule (default: system timezone). */
35
+ timezone?: string;
36
+ /** Days of week to run (0=Sun, 1=Mon, ..., 6=Sat). Empty/undefined = every day. */
37
+ daysOfWeek?: number[];
31
38
  }
32
39
 
33
40
  export interface AutomationCondition {
@@ -318,6 +325,27 @@ export class AutomationManager {
318
325
 
319
326
  async saveRules(rules: AutomationRule[]): Promise<void> {
320
327
  const normalized = rules.map((rule) => ({ ...rule, action: normalizeAction(rule.action) }));
328
+ for (const rule of normalized) {
329
+ const action = rule.action;
330
+ if (action.type === "handoff" || action.type == null) {
331
+ const handoff = action as AutomationHandoffAction;
332
+ if (!handoff.agent || !handoff.agent.trim()) {
333
+ throw new Error(`Rule "${rule.name || rule.id}": handoff action requires a non-empty agent`);
334
+ }
335
+ if (!handoff.task || !handoff.task.trim()) {
336
+ throw new Error(`Rule "${rule.name || rule.id}": handoff action requires a non-empty task`);
337
+ }
338
+ }
339
+ if (action.type === "tool") {
340
+ const tool = action as AutomationToolAction;
341
+ if (!tool.node || !tool.node.trim()) {
342
+ throw new Error(`Rule "${rule.name || rule.id}": tool action requires a non-empty node`);
343
+ }
344
+ if (!tool.tool || !tool.tool.trim()) {
345
+ throw new Error(`Rule "${rule.name || rule.id}": tool action requires a non-empty tool name`);
346
+ }
347
+ }
348
+ }
321
349
  const content: AutomationsFile = { rules: normalized };
322
350
  await writeFile(this.filePath, JSON.stringify(content, null, 2), "utf-8");
323
351
  this.stopScheduleTimers();
@@ -523,7 +551,7 @@ export class AutomationManager {
523
551
  title,
524
552
  detail,
525
553
  progress,
526
- status: success ? "completed" : "error",
554
+ status: success ? "completed" : "failed",
527
555
  },
528
556
  });
529
557
  } catch {
@@ -577,6 +605,13 @@ export class AutomationManager {
577
605
  for (const rule of this.rules) {
578
606
  if (!rule.enabled) continue;
579
607
  if (rule.trigger.type !== "schedule") continue;
608
+
609
+ if (rule.trigger.dailyTime) {
610
+ // Daily schedule: check every 30s if we should fire
611
+ this.scheduleDailyTimer(rule);
612
+ continue;
613
+ }
614
+
580
615
  if (!rule.trigger.intervalMs || rule.trigger.intervalMs < 10_000) continue;
581
616
 
582
617
  const timer = setInterval(() => {
@@ -589,6 +624,60 @@ export class AutomationManager {
589
624
  }
590
625
  }
591
626
 
627
+ private scheduleDailyTimer(rule: AutomationRule): void {
628
+ const CHECK_INTERVAL = 30_000; // Check every 30s
629
+ const fired = new Set<string>(); // Track fired dates to prevent double-fire
630
+
631
+ const timer = setInterval(() => {
632
+ const { dailyTime, timezone, daysOfWeek } = rule.trigger;
633
+ if (!dailyTime) return;
634
+
635
+ const now = new Date();
636
+ // Format current time in the target timezone
637
+ const formatter = new Intl.DateTimeFormat("en-US", {
638
+ timeZone: timezone || undefined,
639
+ hour: "2-digit",
640
+ minute: "2-digit",
641
+ hour12: false,
642
+ weekday: "short",
643
+ year: "numeric",
644
+ month: "2-digit",
645
+ day: "2-digit",
646
+ });
647
+ const parts = Object.fromEntries(
648
+ formatter.formatToParts(now).map((p) => [p.type, p.value]),
649
+ );
650
+
651
+ const currentTime = `${parts.hour}:${parts.minute}`;
652
+ const dateKey = `${parts.year}-${parts.month}-${parts.day}`;
653
+
654
+ // Check time match (within 1-minute window)
655
+ if (currentTime !== dailyTime) return;
656
+
657
+ // Check day-of-week filter
658
+ if (daysOfWeek && daysOfWeek.length > 0) {
659
+ const dayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
660
+ const currentDay = dayMap[parts.weekday] ?? now.getDay();
661
+ if (!daysOfWeek.includes(currentDay)) return;
662
+ }
663
+
664
+ // Prevent double-fire within same date
665
+ if (fired.has(dateKey)) return;
666
+ fired.add(dateKey);
667
+ // Cleanup old entries
668
+ if (fired.size > 7) {
669
+ const entries = [...fired];
670
+ entries.slice(0, entries.length - 7).forEach((k) => fired.delete(k));
671
+ }
672
+
673
+ if (!this.isOnCooldown(rule)) {
674
+ void this.executeRule(rule);
675
+ }
676
+ }, CHECK_INTERVAL);
677
+
678
+ this.scheduleTimers.set(rule.id, timer);
679
+ }
680
+
592
681
  private stopScheduleTimers(): void {
593
682
  for (const timer of this.scheduleTimers.values()) {
594
683
  clearInterval(timer);