maestro-agent 0.0.1 → 0.0.3
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 +316 -2
- package/bin/maestro.ts +5 -0
- package/dist/maestro +0 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/Connections-BMA04Ycg.js +11 -0
- package/dist/web/assets/GanttView-DXjh0gxg.js +49 -0
- package/dist/web/assets/Home-Ct3Ho0Qt.js +1 -0
- package/dist/web/assets/HooksCrons--0kyVJcR.js +11 -0
- package/dist/web/assets/ProjectDetail-B_IqEpFu.js +1 -0
- package/dist/web/assets/Roles-D1tIQzto.js +24 -0
- package/dist/web/assets/Settings-yts4LUmH.js +11 -0
- package/dist/web/assets/Skills-DbuNLjIV.js +12 -0
- package/dist/web/assets/Wizard-vJol8-Y4.js +11 -0
- package/dist/web/assets/WorkspaceChat-DrsLs4m2.js +56 -0
- package/dist/web/assets/WorkspaceDashboard-B9vgrd2Z.js +6 -0
- package/dist/web/assets/WorkspaceNew-DoNGYHCG.js +1 -0
- package/dist/web/assets/WorkspaceProjects-DDp3mUse.js +6 -0
- package/dist/web/assets/WorkspaceSchedules-BTjmCbYG.js +1 -0
- package/dist/web/assets/WorkspaceTasks-mPU-bhKR.js +41 -0
- package/dist/web/assets/activity-CIA8bIA4.js +6 -0
- package/dist/web/assets/addon-fit-BlxrFPDK.js +1 -0
- package/dist/web/assets/arrow-right-S7ID7nDp.js +6 -0
- package/dist/web/assets/badge-DDTUzWIi.js +1 -0
- package/dist/web/assets/circle-check-B3P1qK0Z.js +6 -0
- package/dist/web/assets/clock-f9aYZox0.js +6 -0
- package/dist/web/assets/index-BRo4Du_s.js +11 -0
- package/dist/web/assets/index-C7kx39S9.js +196 -0
- package/dist/web/assets/index-D6LSdZea.css +1 -0
- package/dist/web/assets/plus-BHnOxbns.js +6 -0
- package/dist/web/assets/refresh-cw-BWX04Hg3.js +6 -0
- package/dist/web/assets/save-BLbb_9xz.js +6 -0
- package/dist/web/assets/sparkles-CDr6Dw1e.js +6 -0
- package/dist/web/assets/trash-2-9-ThEdey.js +6 -0
- package/dist/web/assets/useEventStream-DXt2Hmei.js +1 -0
- package/dist/web/assets/x-DVdKPXXy.js +6 -0
- package/dist/web/assets/xterm-DYP7pi_n.css +32 -0
- package/dist/web/assets/xterm-DlVFs1Kw.js +9 -0
- package/dist/web/favicon-512.png +0 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -0
- package/package.json +49 -6
- package/src/api/agents.ts +76 -0
- package/src/api/audit.ts +19 -0
- package/src/api/autopilot.ts +73 -0
- package/src/api/chat.ts +801 -0
- package/src/api/chief.ts +84 -0
- package/src/api/config.ts +39 -0
- package/src/api/gantt.ts +72 -0
- package/src/api/hooks.ts +54 -0
- package/src/api/inbox.ts +125 -0
- package/src/api/lark.ts +32 -0
- package/src/api/memory.ts +37 -0
- package/src/api/ops.ts +89 -0
- package/src/api/projects.ts +105 -0
- package/src/api/roles.ts +123 -0
- package/src/api/runtimes.ts +62 -0
- package/src/api/scheduled-tasks.ts +203 -0
- package/src/api/sessions.ts +479 -0
- package/src/api/skills.ts +386 -0
- package/src/api/tasks.ts +457 -0
- package/src/api/telegram.ts +94 -0
- package/src/api/templates.ts +36 -0
- package/src/api/webhooks.ts +20 -0
- package/src/api/workspaces.ts +150 -0
- package/src/bridges/lark/index.ts +213 -0
- package/src/bridges/telegram/index.ts +273 -0
- package/src/bridges/telegram/polling.ts +185 -0
- package/src/chat/index.ts +86 -0
- package/src/chief/index.ts +461 -0
- package/src/core/cli.ts +333 -0
- package/src/core/db.ts +53 -0
- package/src/core/event-bus.ts +33 -0
- package/src/core/index.ts +6 -0
- package/src/core/migrations.ts +303 -0
- package/src/core/router.ts +69 -0
- package/src/core/schema.sql +232 -0
- package/src/core/server.ts +308 -0
- package/src/core/validate.ts +22 -0
- package/src/discovery/index.ts +194 -0
- package/src/gateway/adapters/telegram.ts +148 -0
- package/src/gateway/index.ts +31 -0
- package/src/gateway/manager.ts +176 -0
- package/src/gateway/types.ts +77 -0
- package/src/inbox/index.ts +500 -0
- package/src/ops/artifact-sync.ts +65 -0
- package/src/ops/autopilot.ts +338 -0
- package/src/ops/gc.ts +252 -0
- package/src/ops/index.ts +226 -0
- package/src/ops/project-serial.ts +52 -0
- package/src/ops/role-dispatch.ts +111 -0
- package/src/ops/runtime-scheduler.ts +447 -0
- package/src/ops/task-blocking.ts +65 -0
- package/src/ops/task-deps.ts +37 -0
- package/src/ops/task-workspace.ts +60 -0
- package/src/roles/index.ts +258 -0
- package/src/roles/prompt-assembler.ts +85 -0
- package/src/roles/workspace-role.ts +155 -0
- package/src/scheduler/index.ts +461 -0
- package/src/session/output-parser.ts +75 -0
- package/src/session/realtime-parser.ts +40 -0
- package/src/skills/builtin.ts +155 -0
- package/src/skills/skill-extractor.ts +452 -0
- package/src/skills/skill-md.ts +282 -0
- package/src/transport/http-api.ts +75 -0
- package/src/transport/index.ts +4 -0
- package/src/transport/local-pty.ts +119 -0
- package/src/transport/ssh.ts +176 -0
- package/src/transport/types.ts +20 -0
- package/src/workflows/index.ts +231 -0
- package/index.js +0 -1
- package/maestro-agent-0.0.1.tgz +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SKILL.md parser and serializer with YAML Frontmatter support.
|
|
6
|
+
*
|
|
7
|
+
* Format:
|
|
8
|
+
* ```markdown
|
|
9
|
+
* ---
|
|
10
|
+
* name: my-skill
|
|
11
|
+
* description: Short description
|
|
12
|
+
* version: 1.0.0
|
|
13
|
+
* metadata:
|
|
14
|
+
* author: you
|
|
15
|
+
* tags: [code, review]
|
|
16
|
+
* argument-hint: "<file_path> [--fix]"
|
|
17
|
+
* platforms:
|
|
18
|
+
* claude: true
|
|
19
|
+
* codex: true
|
|
20
|
+
* gemini: false
|
|
21
|
+
* ---
|
|
22
|
+
*
|
|
23
|
+
* # my-skill
|
|
24
|
+
*
|
|
25
|
+
* Full markdown body here...
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface SkillFrontmatter {
|
|
30
|
+
name: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
metadata?: Record<string, any>;
|
|
34
|
+
"argument-hint"?: string;
|
|
35
|
+
platforms?: Record<string, boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParsedSkillMd {
|
|
39
|
+
frontmatter: SkillFrontmatter;
|
|
40
|
+
body: string;
|
|
41
|
+
raw: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a SKILL.md file content into frontmatter + body.
|
|
46
|
+
* Supports both:
|
|
47
|
+
* - YAML frontmatter delimited by `---`
|
|
48
|
+
* - Legacy format: `# Title\n\nDescription...`
|
|
49
|
+
*/
|
|
50
|
+
export function parseSkillMd(content: string): ParsedSkillMd {
|
|
51
|
+
const raw = content;
|
|
52
|
+
const trimmed = content.trim();
|
|
53
|
+
|
|
54
|
+
// Try YAML frontmatter first
|
|
55
|
+
if (trimmed.startsWith("---")) {
|
|
56
|
+
const endIdx = trimmed.indexOf("---", 3);
|
|
57
|
+
if (endIdx !== -1) {
|
|
58
|
+
const yamlBlock = trimmed.slice(3, endIdx).trim();
|
|
59
|
+
const body = trimmed.slice(endIdx + 3).trim();
|
|
60
|
+
const frontmatter = parseYamlSimple(yamlBlock);
|
|
61
|
+
|
|
62
|
+
// If no name in frontmatter, try to extract from body heading
|
|
63
|
+
if (!frontmatter.name) {
|
|
64
|
+
const headingMatch = body.match(/^#\s+(.+)/m);
|
|
65
|
+
if (headingMatch) frontmatter.name = headingMatch[1].trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { frontmatter, body, raw };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Legacy format: # Title\n\nDescription
|
|
73
|
+
return parseLegacyFormat(trimmed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Serialize a skill definition back to SKILL.md format with frontmatter.
|
|
78
|
+
*/
|
|
79
|
+
export function serializeSkillMd(frontmatter: SkillFrontmatter, body: string): string {
|
|
80
|
+
const yaml = serializeYamlSimple(frontmatter);
|
|
81
|
+
const parts = ["---", yaml, "---", "", body];
|
|
82
|
+
return parts.join("\n") + "\n";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Merge frontmatter updates into an existing SKILL.md, preserving body.
|
|
87
|
+
*/
|
|
88
|
+
export function updateSkillMdFrontmatter(content: string, updates: Partial<SkillFrontmatter>): string {
|
|
89
|
+
const parsed = parseSkillMd(content);
|
|
90
|
+
const merged = { ...parsed.frontmatter, ...updates };
|
|
91
|
+
return serializeSkillMd(merged, parsed.body);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Simple YAML Parser (no dependencies) ────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function parseYamlSimple(yaml: string): SkillFrontmatter {
|
|
97
|
+
const result: any = {};
|
|
98
|
+
const lines = yaml.split("\n");
|
|
99
|
+
let currentKey = "";
|
|
100
|
+
let nestedObj: Record<string, any> | null = null;
|
|
101
|
+
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (!line.trim()) continue;
|
|
104
|
+
|
|
105
|
+
const indent = line.length - line.trimStart().length;
|
|
106
|
+
const trimmedLine = line.trim();
|
|
107
|
+
|
|
108
|
+
// Nested key-value (indented under a parent key)
|
|
109
|
+
if (indent > 0 && nestedObj !== null && currentKey) {
|
|
110
|
+
const kvMatch = trimmedLine.match(/^([^:]+):\s*(.*)$/);
|
|
111
|
+
if (kvMatch) {
|
|
112
|
+
const [, k, v] = kvMatch;
|
|
113
|
+
nestedObj[k.trim()] = parseYamlValue(v.trim());
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Top-level key
|
|
119
|
+
if (indent === 0 && nestedObj !== null) {
|
|
120
|
+
result[currentKey] = nestedObj;
|
|
121
|
+
nestedObj = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const kvMatch = trimmedLine.match(/^([^:]+):\s*(.*)$/);
|
|
125
|
+
if (kvMatch) {
|
|
126
|
+
const [, key, value] = kvMatch;
|
|
127
|
+
const k = key.trim();
|
|
128
|
+
const v = value.trim();
|
|
129
|
+
|
|
130
|
+
if (v === "" || v === "|") {
|
|
131
|
+
// Start of nested object or multiline
|
|
132
|
+
currentKey = k;
|
|
133
|
+
nestedObj = {};
|
|
134
|
+
} else {
|
|
135
|
+
currentKey = k;
|
|
136
|
+
result[k] = parseYamlValue(v);
|
|
137
|
+
nestedObj = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Flush any remaining nested object
|
|
143
|
+
if (nestedObj !== null && currentKey) {
|
|
144
|
+
result[currentKey] = nestedObj;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result as SkillFrontmatter;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseYamlValue(value: string): any {
|
|
151
|
+
if (value === "true") return true;
|
|
152
|
+
if (value === "false") return false;
|
|
153
|
+
if (value === "null" || value === "~") return null;
|
|
154
|
+
if (/^-?\d+$/.test(value)) return parseInt(value, 10);
|
|
155
|
+
if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
|
|
156
|
+
|
|
157
|
+
// Inline array: [a, b, c]
|
|
158
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
159
|
+
const inner = value.slice(1, -1);
|
|
160
|
+
return inner.split(",").map((s) => parseYamlValue(s.trim()));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Quoted string
|
|
164
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
165
|
+
return value.slice(1, -1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function serializeYamlSimple(obj: SkillFrontmatter): string {
|
|
172
|
+
const lines: string[] = [];
|
|
173
|
+
|
|
174
|
+
const simpleKeys: (keyof SkillFrontmatter)[] = ["name", "description", "version", "argument-hint"];
|
|
175
|
+
for (const key of simpleKeys) {
|
|
176
|
+
const val = obj[key];
|
|
177
|
+
if (val !== undefined && val !== null && val !== "") {
|
|
178
|
+
lines.push(`${key}: ${formatYamlValue(val)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (obj.metadata && Object.keys(obj.metadata).length > 0) {
|
|
183
|
+
lines.push("metadata:");
|
|
184
|
+
for (const [k, v] of Object.entries(obj.metadata)) {
|
|
185
|
+
lines.push(` ${k}: ${formatYamlValue(v)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (obj.platforms && Object.keys(obj.platforms).length > 0) {
|
|
190
|
+
lines.push("platforms:");
|
|
191
|
+
for (const [k, v] of Object.entries(obj.platforms)) {
|
|
192
|
+
lines.push(` ${k}: ${v}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function formatYamlValue(val: any): string {
|
|
200
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
201
|
+
if (typeof val === "number") return String(val);
|
|
202
|
+
if (Array.isArray(val)) return `[${val.map((v) => formatYamlValue(v)).join(", ")}]`;
|
|
203
|
+
if (typeof val === "string") {
|
|
204
|
+
if (val.includes(":") || val.includes("#") || val.includes('"') || val.includes("'")) {
|
|
205
|
+
return `"${val.replace(/"/g, '\\"')}"`;
|
|
206
|
+
}
|
|
207
|
+
return val;
|
|
208
|
+
}
|
|
209
|
+
return JSON.stringify(val);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Legacy Format Parser ────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function parseLegacyFormat(content: string): ParsedSkillMd {
|
|
215
|
+
const lines = content.split("\n");
|
|
216
|
+
let name = "";
|
|
217
|
+
let nameIdx = -1;
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < lines.length; i++) {
|
|
220
|
+
const trimmed = lines[i].trim();
|
|
221
|
+
if (!name && trimmed.startsWith("# ")) {
|
|
222
|
+
name = trimmed.slice(2).trim();
|
|
223
|
+
nameIdx = i;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Everything after the title is body
|
|
229
|
+
const bodyLines = nameIdx >= 0 ? lines.slice(nameIdx + 1) : lines;
|
|
230
|
+
const bodyText = bodyLines.join("\n").trim();
|
|
231
|
+
|
|
232
|
+
// First non-empty paragraph after the title is the description
|
|
233
|
+
const paragraphs = bodyText.split(/\n\s*\n/);
|
|
234
|
+
const firstPara = paragraphs[0]?.trim() || "";
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
frontmatter: { name: name || "Untitled", description: firstPara },
|
|
238
|
+
body: content,
|
|
239
|
+
raw: content,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── Directory Loader ────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export interface LoadedSkill {
|
|
246
|
+
frontmatter: SkillFrontmatter;
|
|
247
|
+
body: string;
|
|
248
|
+
files: { name: string; path: string }[];
|
|
249
|
+
dirPath: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Load a complete skill definition from a directory.
|
|
254
|
+
* Reads SKILL.md (with frontmatter), and inventories additional files.
|
|
255
|
+
* Returns null if the directory doesn't contain a valid SKILL.md.
|
|
256
|
+
*/
|
|
257
|
+
export function loadSkillFromDir(dirPath: string): LoadedSkill | null {
|
|
258
|
+
const mdPath = join(dirPath, "SKILL.md");
|
|
259
|
+
if (!existsSync(mdPath)) return null;
|
|
260
|
+
|
|
261
|
+
const content = readFileSync(mdPath, "utf-8");
|
|
262
|
+
const parsed = parseSkillMd(content);
|
|
263
|
+
|
|
264
|
+
// Inventory other files in the skill directory
|
|
265
|
+
const files: { name: string; path: string }[] = [];
|
|
266
|
+
try {
|
|
267
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
if (entry.name === "SKILL.md") continue;
|
|
270
|
+
if (entry.isFile()) {
|
|
271
|
+
files.push({ name: entry.name, path: join(dirPath, entry.name) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
frontmatter: parsed.frontmatter,
|
|
278
|
+
body: parsed.body,
|
|
279
|
+
files,
|
|
280
|
+
dirPath,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Transport, TransportProcess, TransportSpawnOptions } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface HttpApiTransportOptions {
|
|
4
|
+
target: string;
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class HttpApiTransport implements Transport {
|
|
9
|
+
readonly type = "http-api";
|
|
10
|
+
|
|
11
|
+
private readonly target: string;
|
|
12
|
+
private readonly fetchImpl: typeof fetch;
|
|
13
|
+
|
|
14
|
+
constructor(opts: HttpApiTransportOptions) {
|
|
15
|
+
this.target = opts.target.replace(/\/$/, "");
|
|
16
|
+
this.fetchImpl = opts.fetchImpl || fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async spawn(cmd: string, args: string[], opts: TransportSpawnOptions = {}): Promise<TransportProcess> {
|
|
20
|
+
const res = await this.fetchImpl(`${this.target}/spawn`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
cmd,
|
|
25
|
+
args,
|
|
26
|
+
cwd: opts.cwd,
|
|
27
|
+
env: opts.env || {},
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const text = await res.text().catch(() => "");
|
|
33
|
+
throw new Error(`HTTP API transport spawn failed (${res.status}): ${text || res.statusText}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const payload = await res.json() as {
|
|
37
|
+
pid?: number;
|
|
38
|
+
stdout?: string;
|
|
39
|
+
stderr?: string;
|
|
40
|
+
exit_code?: number;
|
|
41
|
+
session_id?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
pid: payload.pid ?? 0,
|
|
46
|
+
stdin: { write: () => {} },
|
|
47
|
+
stdout: textStream(payload.stdout || ""),
|
|
48
|
+
stderr: textStream(payload.stderr || ""),
|
|
49
|
+
exited: Promise.resolve(payload.exit_code ?? 0),
|
|
50
|
+
kill: () => {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async healthcheck(): Promise<{ ok: boolean; detail?: string }> {
|
|
55
|
+
try {
|
|
56
|
+
const res = await this.fetchImpl(`${this.target}/health`, { method: "GET" });
|
|
57
|
+
return { ok: res.ok, detail: res.ok ? "ok" : `status ${res.status}` };
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
return { ok: false, detail: err.message };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close() {
|
|
64
|
+
// No persistent connection for HTTP
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function textStream(text: string): ReadableStream<Uint8Array> {
|
|
69
|
+
return new ReadableStream({
|
|
70
|
+
start(controller) {
|
|
71
|
+
if (text) controller.enqueue(new TextEncoder().encode(text));
|
|
72
|
+
controller.close();
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import { appendFileSync } from "fs";
|
|
3
|
+
import type { Transport, TransportSpawnOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface PtySessionOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
env?: Record<string, string>;
|
|
8
|
+
transcriptPath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PtySession extends EventEmitter {
|
|
12
|
+
id: string;
|
|
13
|
+
pid: number;
|
|
14
|
+
stdin: WritableStream<Uint8Array> | { write(data: string): void };
|
|
15
|
+
write(data: string): void;
|
|
16
|
+
kill(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class LocalPtyTransport implements Transport {
|
|
20
|
+
readonly type = "local-pty" as const;
|
|
21
|
+
|
|
22
|
+
async spawn(id: string, cmd: string, args: string[], opts: PtySessionOptions): Promise<PtySession>;
|
|
23
|
+
async spawn(cmd: string, args: string[], opts?: TransportSpawnOptions): Promise<any>;
|
|
24
|
+
async spawn(...allArgs: any[]): Promise<any> {
|
|
25
|
+
// Overload: (id, cmd, args, opts) or (cmd, args, opts)
|
|
26
|
+
if (typeof allArgs[1] === "string") {
|
|
27
|
+
return this.spawnPty(allArgs[0], allArgs[1], allArgs[2], allArgs[3]);
|
|
28
|
+
}
|
|
29
|
+
// Transport interface: spawn(cmd, args, opts)
|
|
30
|
+
const [cmd, args, opts] = allArgs;
|
|
31
|
+
const proc = Bun.spawn([cmd, ...(args || [])], {
|
|
32
|
+
cwd: opts?.cwd,
|
|
33
|
+
env: { ...process.env, ...(opts?.env || {}) },
|
|
34
|
+
stdin: "pipe",
|
|
35
|
+
stdout: "pipe",
|
|
36
|
+
stderr: "pipe",
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
pid: proc.pid,
|
|
40
|
+
stdin: proc.stdin,
|
|
41
|
+
stdout: proc.stdout,
|
|
42
|
+
stderr: proc.stderr,
|
|
43
|
+
exited: proc.exited,
|
|
44
|
+
kill: () => proc.kill(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private spawnPty(id: string, cmd: string, args: string[], opts: PtySessionOptions): PtySession {
|
|
49
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
50
|
+
cwd: opts.cwd,
|
|
51
|
+
env: { ...process.env, ...opts.env },
|
|
52
|
+
stdin: "pipe",
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const emitter = new EventEmitter() as PtySession;
|
|
58
|
+
emitter.id = id;
|
|
59
|
+
emitter.pid = proc.pid;
|
|
60
|
+
emitter.stdin = proc.stdin;
|
|
61
|
+
emitter.write = (data: string) => {
|
|
62
|
+
proc.stdin.write(data);
|
|
63
|
+
};
|
|
64
|
+
emitter.kill = () => {
|
|
65
|
+
proc.kill();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// stdout 流
|
|
69
|
+
(async () => {
|
|
70
|
+
const reader = proc.stdout.getReader();
|
|
71
|
+
while (true) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done) break;
|
|
74
|
+
const text = new TextDecoder().decode(value);
|
|
75
|
+
if (opts.transcriptPath) {
|
|
76
|
+
appendFileSync(opts.transcriptPath, text);
|
|
77
|
+
}
|
|
78
|
+
emitter.emit("data", text);
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
// stderr 流
|
|
83
|
+
(async () => {
|
|
84
|
+
const reader = proc.stderr.getReader();
|
|
85
|
+
while (true) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
const text = new TextDecoder().decode(value);
|
|
89
|
+
if (opts.transcriptPath) {
|
|
90
|
+
appendFileSync(opts.transcriptPath, text);
|
|
91
|
+
}
|
|
92
|
+
emitter.emit("data", text);
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
|
|
96
|
+
// 退出
|
|
97
|
+
proc.exited.then((code) => {
|
|
98
|
+
emitter.emit("exit", code);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return emitter;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async healthcheck(): Promise<{ ok: boolean; detail?: string }> {
|
|
105
|
+
try {
|
|
106
|
+
const proc = Bun.spawn(["echo", "ok"], { stdout: "pipe" });
|
|
107
|
+
const code = await proc.exited;
|
|
108
|
+
return { ok: code === 0, detail: code === 0 ? "ok" : `exit ${code}` };
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
return { ok: false, detail: err.message };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
close() {
|
|
115
|
+
// No-op for local transport
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const localPty = new LocalPtyTransport();
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Client } from "ssh2";
|
|
2
|
+
import type { ClientChannel, ConnectConfig } from "ssh2";
|
|
3
|
+
import type { Transport, TransportProcess, TransportSpawnOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface SshTransportOptions extends ConnectConfig {
|
|
6
|
+
target?: string;
|
|
7
|
+
clientFactory?: () => Client;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SshTransport implements Transport {
|
|
11
|
+
readonly type = "ssh";
|
|
12
|
+
|
|
13
|
+
private readonly config: ConnectConfig;
|
|
14
|
+
private readonly clientFactory: () => Client;
|
|
15
|
+
private client: Client | null = null;
|
|
16
|
+
private connected = false;
|
|
17
|
+
private connecting: Promise<void> | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(opts: SshTransportOptions) {
|
|
20
|
+
const { target, clientFactory, ...config } = opts;
|
|
21
|
+
this.config = {
|
|
22
|
+
...config,
|
|
23
|
+
host: config.host || target,
|
|
24
|
+
keepaliveInterval: config.keepaliveInterval ?? 15000,
|
|
25
|
+
keepaliveCountMax: config.keepaliveCountMax ?? 3,
|
|
26
|
+
};
|
|
27
|
+
this.clientFactory = clientFactory || (() => new Client());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async ensureConnected(): Promise<Client> {
|
|
31
|
+
if (this.connected && this.client) return this.client;
|
|
32
|
+
if (this.connecting) {
|
|
33
|
+
await this.connecting;
|
|
34
|
+
return this.client!;
|
|
35
|
+
}
|
|
36
|
+
this.connecting = this.doConnect();
|
|
37
|
+
await this.connecting;
|
|
38
|
+
this.connecting = null;
|
|
39
|
+
return this.client!;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private doConnect(): Promise<void> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const client = this.clientFactory();
|
|
45
|
+
client.once("error", (err) => {
|
|
46
|
+
this.connected = false;
|
|
47
|
+
this.client = null;
|
|
48
|
+
reject(err);
|
|
49
|
+
});
|
|
50
|
+
client.once("ready", () => {
|
|
51
|
+
this.client = client;
|
|
52
|
+
this.connected = true;
|
|
53
|
+
// Handle unexpected disconnects
|
|
54
|
+
client.once("close", () => {
|
|
55
|
+
this.connected = false;
|
|
56
|
+
this.client = null;
|
|
57
|
+
});
|
|
58
|
+
client.once("end", () => {
|
|
59
|
+
this.connected = false;
|
|
60
|
+
this.client = null;
|
|
61
|
+
});
|
|
62
|
+
resolve();
|
|
63
|
+
});
|
|
64
|
+
client.connect(this.config);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async spawn(cmd: string, args: string[], opts: TransportSpawnOptions = {}): Promise<TransportProcess> {
|
|
69
|
+
const client = await this.ensureConnected();
|
|
70
|
+
const command = buildRemoteCommand(cmd, args, opts.cwd, opts.env);
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
client.exec(command, (err, channel) => {
|
|
74
|
+
if (err) {
|
|
75
|
+
reject(err);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
resolve(channelProcess(channel));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async healthcheck(): Promise<{ ok: boolean; detail?: string }> {
|
|
84
|
+
try {
|
|
85
|
+
const client = await this.ensureConnected();
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
client.exec("echo ok", (err, channel) => {
|
|
88
|
+
if (err) {
|
|
89
|
+
resolve({ ok: false, detail: err.message });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let output = "";
|
|
93
|
+
channel.on("data", (chunk: Buffer) => { output += chunk.toString(); });
|
|
94
|
+
channel.on("close", () => {
|
|
95
|
+
resolve({ ok: output.trim() === "ok", detail: output.trim() || "no output" });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
return { ok: false, detail: err.message };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
close() {
|
|
105
|
+
if (this.client) {
|
|
106
|
+
this.client.end();
|
|
107
|
+
this.client = null;
|
|
108
|
+
this.connected = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function channelProcess(channel: ClientChannel): TransportProcess {
|
|
114
|
+
let stdoutController: ReadableStreamDefaultController<Uint8Array>;
|
|
115
|
+
let stderrController: ReadableStreamDefaultController<Uint8Array>;
|
|
116
|
+
let resolveExit: (code: number) => void;
|
|
117
|
+
|
|
118
|
+
const exited = new Promise<number>((resolve) => {
|
|
119
|
+
resolveExit = resolve;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
123
|
+
start(controller) {
|
|
124
|
+
stdoutController = controller;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const stderr = new ReadableStream<Uint8Array>({
|
|
128
|
+
start(controller) {
|
|
129
|
+
stderrController = controller;
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
channel.on("data", (chunk: Buffer | string) => {
|
|
134
|
+
stdoutController.enqueue(toBytes(chunk));
|
|
135
|
+
});
|
|
136
|
+
channel.stderr.on("data", (chunk: Buffer | string) => {
|
|
137
|
+
stderrController.enqueue(toBytes(chunk));
|
|
138
|
+
});
|
|
139
|
+
channel.on("close", (code: number | null | undefined) => {
|
|
140
|
+
stdoutController.close();
|
|
141
|
+
stderrController.close();
|
|
142
|
+
resolveExit(code ?? 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
pid: 0,
|
|
147
|
+
stdin: {
|
|
148
|
+
write(data: string) {
|
|
149
|
+
channel.write(data);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
stdout,
|
|
153
|
+
stderr,
|
|
154
|
+
exited,
|
|
155
|
+
kill() {
|
|
156
|
+
channel.close();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildRemoteCommand(cmd: string, args: string[], cwd?: string, env?: Record<string, string>): string {
|
|
162
|
+
const envPrefix = env
|
|
163
|
+
? Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ") + " "
|
|
164
|
+
: "";
|
|
165
|
+
const command = envPrefix + [cmd, ...args].map(shellQuote).join(" ");
|
|
166
|
+
return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function shellQuote(value: string): string {
|
|
170
|
+
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value;
|
|
171
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toBytes(chunk: Buffer | string): Uint8Array {
|
|
175
|
+
return typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
176
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Transport {
|
|
2
|
+
readonly type: "local-pty" | "ssh" | "http-api";
|
|
3
|
+
spawn(cmd: string, args: string[], opts?: TransportSpawnOptions): Promise<TransportProcess>;
|
|
4
|
+
healthcheck?(): Promise<{ ok: boolean; detail?: string }>;
|
|
5
|
+
close?(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TransportSpawnOptions {
|
|
9
|
+
cwd?: string;
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TransportProcess {
|
|
14
|
+
pid: number;
|
|
15
|
+
stdin: { write(data: string): void };
|
|
16
|
+
stdout: ReadableStream<Uint8Array>;
|
|
17
|
+
stderr: ReadableStream<Uint8Array>;
|
|
18
|
+
exited: Promise<number>;
|
|
19
|
+
kill(): void;
|
|
20
|
+
}
|