homaruscc 0.2.0 → 0.3.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/README.md +17 -3
- package/dashboard/dist/assets/index-Xu4GUpcF.js +49 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/favicon.png +0 -0
- package/dashboard/dist/index.html +15 -0
- package/dist/agent-registry.d.ts +9 -12
- package/dist/agent-registry.d.ts.map +1 -1
- package/dist/agent-registry.js +44 -113
- package/dist/agent-registry.js.map +1 -1
- package/dist/dashboard-server.d.ts.map +1 -1
- package/dist/dashboard-server.js +293 -3
- package/dist/dashboard-server.js.map +1 -1
- package/dist/homaruscc.d.ts.map +1 -1
- package/dist/homaruscc.js +1 -2
- package/dist/homaruscc.js.map +1 -1
- package/dist/telegram-adapter.d.ts +3 -0
- package/dist/telegram-adapter.d.ts.map +1 -1
- package/dist/telegram-adapter.js +90 -3
- package/dist/telegram-adapter.js.map +1 -1
- package/package.json +2 -1
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
8
|
+
<link rel="apple-touch-icon" href="/favicon.png" />
|
|
9
|
+
<title>HomarUScc Dashboard</title>
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-Xu4GUpcF.js"></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
package/dist/agent-registry.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { Event, Logger } from "./types.js";
|
|
2
|
-
export type AgentStatus = "running" | "completed" | "failed";
|
|
2
|
+
export type AgentStatus = "running" | "completed" | "failed" | "timeout";
|
|
3
3
|
export interface AgentEntry {
|
|
4
4
|
id: string;
|
|
5
5
|
description: string;
|
|
6
6
|
status: AgentStatus;
|
|
7
7
|
startTime: number;
|
|
8
|
-
outputFile?: string;
|
|
9
8
|
result?: string;
|
|
10
9
|
error?: string;
|
|
11
10
|
}
|
|
@@ -14,11 +13,11 @@ export declare class AgentRegistry {
|
|
|
14
13
|
private maxConcurrent;
|
|
15
14
|
private emitFn;
|
|
16
15
|
private logger;
|
|
17
|
-
private
|
|
18
|
-
private
|
|
19
|
-
constructor(logger: Logger, maxConcurrent?: number,
|
|
16
|
+
private timeoutMs;
|
|
17
|
+
private timeoutTimer;
|
|
18
|
+
constructor(logger: Logger, maxConcurrent?: number, timeoutMs?: number);
|
|
20
19
|
setEmitter(fn: (event: Event) => void): void;
|
|
21
|
-
register(id: string, description: string
|
|
20
|
+
register(id: string, description: string): boolean;
|
|
22
21
|
getAll(): AgentEntry[];
|
|
23
22
|
get(id: string): AgentEntry | null;
|
|
24
23
|
complete(id: string, result: string): void;
|
|
@@ -26,12 +25,10 @@ export declare class AgentRegistry {
|
|
|
26
25
|
cleanup(id: string): void;
|
|
27
26
|
getAvailableSlots(): number;
|
|
28
27
|
getActiveCount(): number;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
private readTail;
|
|
34
|
-
private extractSummary;
|
|
28
|
+
private startTimeoutChecker;
|
|
29
|
+
private stopTimeoutChecker;
|
|
30
|
+
stop(): void;
|
|
31
|
+
private checkTimeouts;
|
|
35
32
|
private resolve;
|
|
36
33
|
private emit;
|
|
37
34
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-registry.d.ts","sourceRoot":"","sources":["../src/agent-registry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"agent-registry.d.ts","sourceRoot":"","sources":["../src/agent-registry.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEzE,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAOD,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAiC;IAC/C,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,MAAM,CAAyC;IACvD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,YAAY,CAA+C;gBAEvD,MAAM,EAAE,MAAM,EAAE,aAAa,SAAI,EAAE,SAAS,SAAqB;IAM7E,UAAU,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI;IAI5C,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO;IAwBlD,MAAM,IAAI,UAAU,EAAE;IAItB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI;IAKlC,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAY1C,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAYrC,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAQzB,iBAAiB,IAAI,MAAM;IAI3B,cAAc,IAAI,MAAM;IASxB,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,kBAAkB;IAO1B,IAAI,IAAI,IAAI;IAIZ,OAAO,CAAC,aAAa;IAcrB,OAAO,CAAC,OAAO;IASf,OAAO,CAAC,IAAI;CAcb"}
|
package/dist/agent-registry.js
CHANGED
|
@@ -1,30 +1,25 @@
|
|
|
1
|
-
// CRC: crc-AgentRegistry.md | Seq: seq-agent-dispatch.md
|
|
1
|
+
// CRC: crc-AgentRegistry.md | Seq: seq-agent-dispatch.md
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const STABLE_THRESHOLD_MS = 10_000;
|
|
8
|
-
// R156: Number of bytes to read from file tail for marker detection
|
|
9
|
-
const TAIL_BYTES = 512;
|
|
3
|
+
// Default timeout: 30 minutes
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
5
|
+
// How often to check for timed-out agents
|
|
6
|
+
const TIMEOUT_CHECK_INTERVAL_MS = 60_000;
|
|
10
7
|
export class AgentRegistry {
|
|
11
8
|
agents = new Map();
|
|
12
9
|
maxConcurrent;
|
|
13
10
|
emitFn = null;
|
|
14
11
|
logger;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
pollTimer = null;
|
|
19
|
-
constructor(logger, maxConcurrent = 3, pollIntervalMs = 5000) {
|
|
12
|
+
timeoutMs;
|
|
13
|
+
timeoutTimer = null;
|
|
14
|
+
constructor(logger, maxConcurrent = 3, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
20
15
|
this.logger = logger;
|
|
21
16
|
this.maxConcurrent = maxConcurrent;
|
|
22
|
-
this.
|
|
17
|
+
this.timeoutMs = timeoutMs;
|
|
23
18
|
}
|
|
24
19
|
setEmitter(fn) {
|
|
25
20
|
this.emitFn = fn;
|
|
26
21
|
}
|
|
27
|
-
register(id, description
|
|
22
|
+
register(id, description) {
|
|
28
23
|
const active = this.getActiveCount();
|
|
29
24
|
if (active >= this.maxConcurrent) {
|
|
30
25
|
this.logger.warn("Agent registry at capacity", { active, max: this.maxConcurrent });
|
|
@@ -35,9 +30,12 @@ export class AgentRegistry {
|
|
|
35
30
|
description,
|
|
36
31
|
status: "running",
|
|
37
32
|
startTime: Date.now(),
|
|
38
|
-
outputFile,
|
|
39
33
|
});
|
|
40
34
|
this.logger.info("Agent registered", { id, description });
|
|
35
|
+
// Start timeout checker if not already running
|
|
36
|
+
if (!this.timeoutTimer) {
|
|
37
|
+
this.startTimeoutChecker();
|
|
38
|
+
}
|
|
41
39
|
return true;
|
|
42
40
|
}
|
|
43
41
|
getAll() {
|
|
@@ -46,7 +44,7 @@ export class AgentRegistry {
|
|
|
46
44
|
get(id) {
|
|
47
45
|
return this.agents.get(id) ?? null;
|
|
48
46
|
}
|
|
49
|
-
//
|
|
47
|
+
// Called via POST /api/agents/:id/complete callback from the agent itself
|
|
50
48
|
complete(id, result) {
|
|
51
49
|
const agent = this.resolve(id);
|
|
52
50
|
if (!agent)
|
|
@@ -62,14 +60,19 @@ export class AgentRegistry {
|
|
|
62
60
|
const agent = this.resolve(id);
|
|
63
61
|
if (!agent)
|
|
64
62
|
return;
|
|
63
|
+
if (agent.status !== "running")
|
|
64
|
+
return;
|
|
65
65
|
agent.status = "failed";
|
|
66
66
|
agent.error = error;
|
|
67
67
|
this.emit("agent_failed", id, agent.description, { error });
|
|
68
68
|
this.logger.warn("Agent failed", { id, error });
|
|
69
69
|
}
|
|
70
|
-
// R160: Cleanup removes agent and any associated polling state
|
|
71
70
|
cleanup(id) {
|
|
72
71
|
this.agents.delete(id);
|
|
72
|
+
// Stop timeout checker if no agents remain
|
|
73
|
+
if (this.getActiveCount() === 0) {
|
|
74
|
+
this.stopTimeoutChecker();
|
|
75
|
+
}
|
|
73
76
|
}
|
|
74
77
|
getAvailableSlots() {
|
|
75
78
|
return Math.max(0, this.maxConcurrent - this.getActiveCount());
|
|
@@ -82,110 +85,38 @@ export class AgentRegistry {
|
|
|
82
85
|
}
|
|
83
86
|
return count;
|
|
84
87
|
}
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
if (this.
|
|
88
|
+
// Periodic check for agents that have exceeded the timeout
|
|
89
|
+
startTimeoutChecker() {
|
|
90
|
+
if (this.timeoutTimer)
|
|
88
91
|
return;
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.pollTimer.unref();
|
|
93
|
-
}
|
|
94
|
-
this.logger.info("Agent completion polling started", { intervalMs: this.pollIntervalMs });
|
|
95
|
-
}
|
|
96
|
-
// R159: Stop global polling interval
|
|
97
|
-
stopPolling() {
|
|
98
|
-
if (this.pollTimer) {
|
|
99
|
-
clearInterval(this.pollTimer);
|
|
100
|
-
this.pollTimer = null;
|
|
101
|
-
this.logger.info("Agent completion polling stopped");
|
|
92
|
+
this.timeoutTimer = setInterval(() => this.checkTimeouts(), TIMEOUT_CHECK_INTERVAL_MS);
|
|
93
|
+
if (this.timeoutTimer && typeof this.timeoutTimer === "object" && "unref" in this.timeoutTimer) {
|
|
94
|
+
this.timeoutTimer.unref();
|
|
102
95
|
}
|
|
103
96
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (agent.status !== "running" || !agent.outputFile)
|
|
109
|
-
continue;
|
|
110
|
-
try {
|
|
111
|
-
this.checkAgentFile(agent);
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
// R161: Log and skip on errors
|
|
115
|
-
this.logger.debug("Poll check error for agent", {
|
|
116
|
-
id: agent.id,
|
|
117
|
-
error: String(err),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
97
|
+
stopTimeoutChecker() {
|
|
98
|
+
if (this.timeoutTimer) {
|
|
99
|
+
clearInterval(this.timeoutTimer);
|
|
100
|
+
this.timeoutTimer = null;
|
|
120
101
|
}
|
|
121
102
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let stat;
|
|
125
|
-
try {
|
|
126
|
-
stat = statSync(agent.outputFile);
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return; // File does not exist yet — skip
|
|
130
|
-
}
|
|
131
|
-
// Skip empty files
|
|
132
|
-
if (stat.size === 0)
|
|
133
|
-
return;
|
|
134
|
-
// R156: Read the tail of the file
|
|
135
|
-
const tail = this.readTail(agent.outputFile, stat.size);
|
|
136
|
-
if (!tail)
|
|
137
|
-
return;
|
|
138
|
-
// R156: Check for completion markers in the tail
|
|
139
|
-
const hasMarker = COMPLETION_MARKERS.some((m) => tail.includes(m));
|
|
140
|
-
if (hasMarker) {
|
|
141
|
-
this.logger.info("Detected completion marker in agent output", { id: agent.id });
|
|
142
|
-
this.complete(agent.id, this.extractSummary(tail));
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
// R156: Check for stable mtime (no writes in STABLE_THRESHOLD_MS)
|
|
146
|
-
const age = Date.now() - stat.mtimeMs;
|
|
147
|
-
if (age >= STABLE_THRESHOLD_MS) {
|
|
148
|
-
this.logger.info("Detected stable output file for agent", {
|
|
149
|
-
id: agent.id,
|
|
150
|
-
stableForMs: Math.round(age),
|
|
151
|
-
});
|
|
152
|
-
this.complete(agent.id, this.extractSummary(tail));
|
|
153
|
-
}
|
|
103
|
+
stop() {
|
|
104
|
+
this.stopTimeoutChecker();
|
|
154
105
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
catch {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
if (fd !== null) {
|
|
171
|
-
try {
|
|
172
|
-
closeSync(fd);
|
|
173
|
-
}
|
|
174
|
-
catch { /* ignore */ }
|
|
106
|
+
checkTimeouts() {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
for (const agent of this.agents.values()) {
|
|
109
|
+
if (agent.status !== "running")
|
|
110
|
+
continue;
|
|
111
|
+
const elapsed = now - agent.startTime;
|
|
112
|
+
if (elapsed >= this.timeoutMs) {
|
|
113
|
+
agent.status = "timeout";
|
|
114
|
+
agent.error = `Agent timed out after ${Math.round(elapsed / 60_000)}m`;
|
|
115
|
+
this.emit("agent_timeout", agent.id, agent.description, { error: agent.error });
|
|
116
|
+
this.logger.warn("Agent timed out", { id: agent.id, elapsedMs: elapsed });
|
|
175
117
|
}
|
|
176
118
|
}
|
|
177
119
|
}
|
|
178
|
-
// Extract a brief summary from the file tail for the completion result
|
|
179
|
-
extractSummary(tail) {
|
|
180
|
-
// Try to find the last JSON line that looks like a result
|
|
181
|
-
const lines = tail.split("\n").filter((l) => l.trim().length > 0);
|
|
182
|
-
const lastLine = lines[lines.length - 1] ?? "";
|
|
183
|
-
// Truncate to a reasonable length for the event payload
|
|
184
|
-
if (lastLine.length > 200) {
|
|
185
|
-
return lastLine.slice(0, 200) + "...";
|
|
186
|
-
}
|
|
187
|
-
return lastLine || "(output file completed)";
|
|
188
|
-
}
|
|
189
120
|
resolve(id) {
|
|
190
121
|
const agent = this.agents.get(id);
|
|
191
122
|
if (!agent) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-registry.js","sourceRoot":"","sources":["../src/agent-registry.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"agent-registry.js","sourceRoot":"","sources":["../src/agent-registry.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-server.d.ts","sourceRoot":"","sources":["../src/dashboard-server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dashboard-server.d.ts","sourceRoot":"","sources":["../src/dashboard-server.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAe/D,qBAAa,eAAe;IAC1B,OAAO,CAAC,GAAG,CAAsB;IACjC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,GAAG,CAAkB;IAC7B,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,IAAI,CAAY;IACxB,OAAO,CAAC,gBAAgB,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,iBAAiB,CAAoB;gBAEjC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,gBAAgB,EAAE,gBAAgB;IAkBvF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAuB5B,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,gBAAgB;IAclB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAe3B,cAAc,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAalC,OAAO,CAAC,WAAW;IAicnB,OAAO,CAAC,cAAc;IA4CtB,OAAO,CAAC,eAAe;IAuCvB,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,MAAM;IAMd,OAAO,CAAC,SAAS;CAQlB"}
|
package/dist/dashboard-server.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Dashboard server — Express + WebSocket for the web dashboard
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { resolve, join } from "node:path";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import express from "express";
|
|
8
9
|
import { WebSocketServer } from "ws";
|
|
9
10
|
import { createMcpTools } from "./mcp-tools.js";
|
|
@@ -189,8 +190,8 @@ export class DashboardServer {
|
|
|
189
190
|
});
|
|
190
191
|
// --- Agent Registry endpoints (R137, R138, R139) ---
|
|
191
192
|
this.app.post("/api/agents", express.json(), (req, res) => {
|
|
192
|
-
const { id, description
|
|
193
|
-
const ok = this.loop.getAgentRegistry().register(id, description
|
|
193
|
+
const { id, description } = req.body;
|
|
194
|
+
const ok = this.loop.getAgentRegistry().register(id, description);
|
|
194
195
|
if (ok) {
|
|
195
196
|
res.json({ ok: true });
|
|
196
197
|
}
|
|
@@ -217,6 +218,19 @@ export class DashboardServer {
|
|
|
217
218
|
this.loop.getAgentRegistry().cleanup(req.params.id);
|
|
218
219
|
res.json({ ok: true });
|
|
219
220
|
});
|
|
221
|
+
// Agent completion callback — agents POST here when done
|
|
222
|
+
this.app.post("/api/agents/:id/complete", express.json(), (req, res) => {
|
|
223
|
+
const { id } = req.params;
|
|
224
|
+
const { result, error } = req.body;
|
|
225
|
+
const registry = this.loop.getAgentRegistry();
|
|
226
|
+
if (error) {
|
|
227
|
+
registry.fail(id, error);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
registry.complete(id, result ?? "(completed)");
|
|
231
|
+
}
|
|
232
|
+
res.json({ ok: true });
|
|
233
|
+
});
|
|
220
234
|
this.app.get("/api/tool-list", (_req, res) => {
|
|
221
235
|
res.json(this.mcpTools.map((t) => ({
|
|
222
236
|
name: t.name,
|
|
@@ -262,6 +276,282 @@ export class DashboardServer {
|
|
|
262
276
|
res.status(500).json({ error: `Resource error: ${String(err)}` });
|
|
263
277
|
}
|
|
264
278
|
});
|
|
279
|
+
// --- Apps Platform endpoints ---
|
|
280
|
+
const appsDir = join(homedir(), ".homaruscc", "apps");
|
|
281
|
+
this.app.get("/api/apps", (_req, res) => {
|
|
282
|
+
if (!existsSync(appsDir)) {
|
|
283
|
+
res.json([]);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const apps = [];
|
|
287
|
+
for (const slug of readdirSync(appsDir)) {
|
|
288
|
+
const manifestPath = join(appsDir, slug, "manifest.json");
|
|
289
|
+
if (existsSync(manifestPath)) {
|
|
290
|
+
try {
|
|
291
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
292
|
+
apps.push({ ...manifest, slug });
|
|
293
|
+
}
|
|
294
|
+
catch { /* skip invalid manifests */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
res.json(apps);
|
|
298
|
+
});
|
|
299
|
+
this.app.get("/api/apps/:slug/data", (req, res) => {
|
|
300
|
+
const dataPath = join(appsDir, req.params.slug, "data.json");
|
|
301
|
+
if (!existsSync(dataPath)) {
|
|
302
|
+
res.json({});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
res.json(JSON.parse(readFileSync(dataPath, "utf8")));
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
res.json({});
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
this.app.put("/api/apps/:slug/data", express.json(), (req, res) => {
|
|
313
|
+
const dataPath = join(appsDir, req.params.slug, "data.json");
|
|
314
|
+
writeFileSync(dataPath, JSON.stringify(req.body, null, 2));
|
|
315
|
+
res.json({ ok: true });
|
|
316
|
+
});
|
|
317
|
+
// Serve static files from app directories (icons, etc.)
|
|
318
|
+
this.app.get("/api/apps/:slug/static/:file", (req, res) => {
|
|
319
|
+
const filePath = join(appsDir, req.params.slug, req.params.file);
|
|
320
|
+
if (!existsSync(filePath)) {
|
|
321
|
+
res.status(404).end();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
res.sendFile(filePath);
|
|
325
|
+
});
|
|
326
|
+
// --- Kanban task CRUD ---
|
|
327
|
+
const kanbanDataPath = join(appsDir, "kanban", "data.json");
|
|
328
|
+
const readKanban = () => {
|
|
329
|
+
if (!existsSync(kanbanDataPath))
|
|
330
|
+
return { tasks: [] };
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(readFileSync(kanbanDataPath, "utf8"));
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return { tasks: [] };
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const writeKanban = (data) => {
|
|
339
|
+
const dir = join(appsDir, "kanban");
|
|
340
|
+
if (!existsSync(dir))
|
|
341
|
+
mkdirSync(dir, { recursive: true });
|
|
342
|
+
writeFileSync(kanbanDataPath, JSON.stringify(data, null, 2));
|
|
343
|
+
};
|
|
344
|
+
// List all tasks
|
|
345
|
+
this.app.get("/api/kanban/tasks", (_req, res) => {
|
|
346
|
+
res.json(readKanban().tasks);
|
|
347
|
+
});
|
|
348
|
+
// Create a task
|
|
349
|
+
this.app.post("/api/kanban/tasks", express.json(), (req, res) => {
|
|
350
|
+
const data = readKanban();
|
|
351
|
+
const task = {
|
|
352
|
+
id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
353
|
+
title: req.body.title ?? "Untitled",
|
|
354
|
+
description: req.body.description ?? "",
|
|
355
|
+
assignee: req.body.assignee ?? "max",
|
|
356
|
+
status: req.body.status ?? "todo",
|
|
357
|
+
created: new Date().toISOString(),
|
|
358
|
+
updated: new Date().toISOString(),
|
|
359
|
+
};
|
|
360
|
+
data.tasks.push(task);
|
|
361
|
+
writeKanban(data);
|
|
362
|
+
res.json(task);
|
|
363
|
+
});
|
|
364
|
+
// Update a task
|
|
365
|
+
this.app.patch("/api/kanban/tasks/:id", express.json(), (req, res) => {
|
|
366
|
+
const data = readKanban();
|
|
367
|
+
const task = data.tasks.find((t) => t.id === req.params.id);
|
|
368
|
+
if (!task) {
|
|
369
|
+
res.status(404).json({ error: "Task not found" });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const { title, description, assignee, status } = req.body;
|
|
373
|
+
if (title !== undefined)
|
|
374
|
+
task.title = title;
|
|
375
|
+
if (description !== undefined)
|
|
376
|
+
task.description = description;
|
|
377
|
+
if (assignee !== undefined)
|
|
378
|
+
task.assignee = assignee;
|
|
379
|
+
if (status !== undefined)
|
|
380
|
+
task.status = status;
|
|
381
|
+
task.updated = new Date().toISOString();
|
|
382
|
+
writeKanban(data);
|
|
383
|
+
res.json(task);
|
|
384
|
+
});
|
|
385
|
+
// Delete a task
|
|
386
|
+
this.app.delete("/api/kanban/tasks/:id", (req, res) => {
|
|
387
|
+
const data = readKanban();
|
|
388
|
+
data.tasks = data.tasks.filter((t) => t.id !== req.params.id);
|
|
389
|
+
writeKanban(data);
|
|
390
|
+
res.json({ ok: true });
|
|
391
|
+
});
|
|
392
|
+
// --- CRM CRUD (markdown files with YAML frontmatter) ---
|
|
393
|
+
const crmDir = join(homedir(), ".homaruscc", "crm");
|
|
394
|
+
const parseCrmFile = (slug, content) => {
|
|
395
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
396
|
+
if (!fmMatch)
|
|
397
|
+
return { slug, name: slug, aliases: [], tags: [], connections: [], context: "", source: "manual", lastMentioned: new Date().toISOString().slice(0, 10), created: new Date().toISOString().slice(0, 10), notes: content };
|
|
398
|
+
const fm = {};
|
|
399
|
+
for (const line of fmMatch[1].split("\n")) {
|
|
400
|
+
const colonIdx = line.indexOf(":");
|
|
401
|
+
if (colonIdx === -1)
|
|
402
|
+
continue;
|
|
403
|
+
const key = line.slice(0, colonIdx).trim();
|
|
404
|
+
let val = line.slice(colonIdx + 1).trim();
|
|
405
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
406
|
+
try {
|
|
407
|
+
fm[key] = JSON.parse(val);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
fm[key] = val;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
fm[key] = val;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Parse connections from YAML array format
|
|
418
|
+
const connections = [];
|
|
419
|
+
if (Array.isArray(fm.connections)) {
|
|
420
|
+
for (const c of fm.connections) {
|
|
421
|
+
if (typeof c === "object" && c !== null)
|
|
422
|
+
connections.push(c);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
slug,
|
|
427
|
+
name: fm.name ?? slug,
|
|
428
|
+
aliases: Array.isArray(fm.aliases) ? fm.aliases : [],
|
|
429
|
+
email: fm.email,
|
|
430
|
+
phone: fm.phone,
|
|
431
|
+
social: fm.social,
|
|
432
|
+
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
433
|
+
connections,
|
|
434
|
+
context: fm.context ?? "",
|
|
435
|
+
source: fm.source ?? "manual",
|
|
436
|
+
lastMentioned: fm.lastMentioned ?? new Date().toISOString().slice(0, 10),
|
|
437
|
+
created: fm.created ?? new Date().toISOString().slice(0, 10),
|
|
438
|
+
notes: fmMatch[2].trim(),
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
const contactToMarkdown = (c) => {
|
|
442
|
+
const lines = [
|
|
443
|
+
"---",
|
|
444
|
+
`name: ${c.name}`,
|
|
445
|
+
`aliases: ${JSON.stringify(c.aliases ?? [])}`,
|
|
446
|
+
];
|
|
447
|
+
if (c.email)
|
|
448
|
+
lines.push(`email: ${c.email}`);
|
|
449
|
+
if (c.phone)
|
|
450
|
+
lines.push(`phone: ${c.phone}`);
|
|
451
|
+
if (c.social)
|
|
452
|
+
lines.push(`social: ${JSON.stringify(c.social)}`);
|
|
453
|
+
lines.push(`tags: ${JSON.stringify(c.tags ?? [])}`);
|
|
454
|
+
if (c.connections?.length) {
|
|
455
|
+
lines.push(`connections: ${JSON.stringify(c.connections)}`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
lines.push("connections: []");
|
|
459
|
+
}
|
|
460
|
+
lines.push(`context: ${c.context ?? ""}`);
|
|
461
|
+
lines.push(`source: ${c.source ?? "manual"}`);
|
|
462
|
+
lines.push(`lastMentioned: ${c.lastMentioned ?? new Date().toISOString().slice(0, 10)}`);
|
|
463
|
+
lines.push(`created: ${c.created ?? new Date().toISOString().slice(0, 10)}`);
|
|
464
|
+
lines.push("---");
|
|
465
|
+
if (c.notes)
|
|
466
|
+
lines.push("", c.notes);
|
|
467
|
+
return lines.join("\n") + "\n";
|
|
468
|
+
};
|
|
469
|
+
const slugify = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
470
|
+
// List all contacts
|
|
471
|
+
this.app.get("/api/crm/contacts", (_req, res) => {
|
|
472
|
+
if (!existsSync(crmDir)) {
|
|
473
|
+
res.json([]);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const contacts = [];
|
|
477
|
+
for (const file of readdirSync(crmDir)) {
|
|
478
|
+
if (!file.endsWith(".md"))
|
|
479
|
+
continue;
|
|
480
|
+
try {
|
|
481
|
+
const content = readFileSync(join(crmDir, file), "utf8");
|
|
482
|
+
contacts.push(parseCrmFile(file.replace(/\.md$/, ""), content));
|
|
483
|
+
}
|
|
484
|
+
catch { /* skip */ }
|
|
485
|
+
}
|
|
486
|
+
contacts.sort((a, b) => b.lastMentioned.localeCompare(a.lastMentioned));
|
|
487
|
+
res.json(contacts);
|
|
488
|
+
});
|
|
489
|
+
// Get single contact
|
|
490
|
+
this.app.get("/api/crm/contacts/:slug", (req, res) => {
|
|
491
|
+
const filePath = join(crmDir, `${req.params.slug}.md`);
|
|
492
|
+
if (!existsSync(filePath)) {
|
|
493
|
+
res.status(404).json({ error: "Contact not found" });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const content = readFileSync(filePath, "utf8");
|
|
497
|
+
res.json(parseCrmFile(req.params.slug, content));
|
|
498
|
+
});
|
|
499
|
+
// Create contact
|
|
500
|
+
this.app.post("/api/crm/contacts", express.json(), (req, res) => {
|
|
501
|
+
if (!existsSync(crmDir))
|
|
502
|
+
mkdirSync(crmDir, { recursive: true });
|
|
503
|
+
const body = req.body;
|
|
504
|
+
if (!body.name) {
|
|
505
|
+
res.status(400).json({ error: "Name required" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const slug = slugify(body.name);
|
|
509
|
+
const filePath = join(crmDir, `${slug}.md`);
|
|
510
|
+
const contact = {
|
|
511
|
+
slug,
|
|
512
|
+
name: body.name,
|
|
513
|
+
aliases: body.aliases ?? [],
|
|
514
|
+
email: body.email,
|
|
515
|
+
phone: body.phone,
|
|
516
|
+
social: body.social,
|
|
517
|
+
tags: body.tags ?? [],
|
|
518
|
+
connections: body.connections ?? [],
|
|
519
|
+
context: body.context ?? "",
|
|
520
|
+
source: body.source ?? "manual",
|
|
521
|
+
lastMentioned: new Date().toISOString().slice(0, 10),
|
|
522
|
+
created: new Date().toISOString().slice(0, 10),
|
|
523
|
+
notes: body.notes ?? "",
|
|
524
|
+
};
|
|
525
|
+
writeFileSync(filePath, contactToMarkdown(contact));
|
|
526
|
+
res.json(contact);
|
|
527
|
+
});
|
|
528
|
+
// Update contact
|
|
529
|
+
this.app.patch("/api/crm/contacts/:slug", express.json(), (req, res) => {
|
|
530
|
+
const filePath = join(crmDir, `${req.params.slug}.md`);
|
|
531
|
+
if (!existsSync(filePath)) {
|
|
532
|
+
res.status(404).json({ error: "Contact not found" });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const existing = parseCrmFile(req.params.slug, readFileSync(filePath, "utf8"));
|
|
536
|
+
const body = req.body;
|
|
537
|
+
const updated = { ...existing, ...body, slug: existing.slug };
|
|
538
|
+
writeFileSync(filePath, contactToMarkdown(updated));
|
|
539
|
+
// If name changed, rename the file
|
|
540
|
+
if (body.name && slugify(body.name) !== existing.slug) {
|
|
541
|
+
const newSlug = slugify(body.name);
|
|
542
|
+
const newPath = join(crmDir, `${newSlug}.md`);
|
|
543
|
+
renameSync(filePath, newPath);
|
|
544
|
+
updated.slug = newSlug;
|
|
545
|
+
}
|
|
546
|
+
res.json(updated);
|
|
547
|
+
});
|
|
548
|
+
// Delete contact
|
|
549
|
+
this.app.delete("/api/crm/contacts/:slug", (req, res) => {
|
|
550
|
+
const filePath = join(crmDir, `${req.params.slug}.md`);
|
|
551
|
+
if (existsSync(filePath))
|
|
552
|
+
unlinkSync(filePath);
|
|
553
|
+
res.json({ ok: true });
|
|
554
|
+
});
|
|
265
555
|
// Serve built dashboard in production
|
|
266
556
|
const distPath = resolve(import.meta.dirname ?? __dirname, "../dashboard/dist");
|
|
267
557
|
if (existsSync(distPath)) {
|