maestro-agent 0.0.1 → 0.0.2

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 (94) hide show
  1. package/README.md +316 -2
  2. package/bin/maestro.ts +5 -0
  3. package/dist/maestro +0 -0
  4. package/dist/web/assets/Connections-DV2Kql1Z.js +1 -0
  5. package/dist/web/assets/GanttView-CCT_rFpY.js +39 -0
  6. package/dist/web/assets/Home-BFbUIh2z.js +1 -0
  7. package/dist/web/assets/HooksCrons-ASM5-jDm.js +1 -0
  8. package/dist/web/assets/ProjectDetail-KZZi6IAd.js +1 -0
  9. package/dist/web/assets/Roles-KQ94PG3H.js +4 -0
  10. package/dist/web/assets/ScheduledTasks-CdJHJpEV.js +1 -0
  11. package/dist/web/assets/Settings-CTflMta-.js +1 -0
  12. package/dist/web/assets/Skills-D09W1mwX.js +2 -0
  13. package/dist/web/assets/Wizard-CW6B0wc3.js +1 -0
  14. package/dist/web/assets/WorkspaceChat-CthETL_A.js +1 -0
  15. package/dist/web/assets/WorkspaceDashboard-DTAesQuT.js +1 -0
  16. package/dist/web/assets/WorkspaceNew-Em4msIKn.js +1 -0
  17. package/dist/web/assets/WorkspaceProjects-Dxg2BpQy.js +1 -0
  18. package/dist/web/assets/WorkspaceTasks-C20mnnkP.js +1 -0
  19. package/dist/web/assets/index-B1k33vcR.js +11 -0
  20. package/dist/web/assets/index-Bk2hHz7P.css +1 -0
  21. package/dist/web/assets/index-Ddy5AJwx.js +61 -0
  22. package/dist/web/assets/useEventStream-DTID465I.js +1 -0
  23. package/dist/web/index.html +13 -0
  24. package/package.json +49 -6
  25. package/src/api/agents.ts +76 -0
  26. package/src/api/audit.ts +19 -0
  27. package/src/api/autopilot.ts +73 -0
  28. package/src/api/chat.ts +801 -0
  29. package/src/api/chief.ts +84 -0
  30. package/src/api/config.ts +39 -0
  31. package/src/api/gantt.ts +72 -0
  32. package/src/api/hooks.ts +54 -0
  33. package/src/api/inbox.ts +125 -0
  34. package/src/api/lark.ts +32 -0
  35. package/src/api/memory.ts +37 -0
  36. package/src/api/ops.ts +89 -0
  37. package/src/api/projects.ts +105 -0
  38. package/src/api/roles.ts +123 -0
  39. package/src/api/runtimes.ts +62 -0
  40. package/src/api/scheduled-tasks.ts +203 -0
  41. package/src/api/sessions.ts +479 -0
  42. package/src/api/skills.ts +386 -0
  43. package/src/api/tasks.ts +457 -0
  44. package/src/api/telegram.ts +94 -0
  45. package/src/api/templates.ts +36 -0
  46. package/src/api/webhooks.ts +20 -0
  47. package/src/api/workspaces.ts +150 -0
  48. package/src/bridges/lark/index.ts +213 -0
  49. package/src/bridges/telegram/index.ts +273 -0
  50. package/src/bridges/telegram/polling.ts +185 -0
  51. package/src/chat/index.ts +86 -0
  52. package/src/chief/index.ts +461 -0
  53. package/src/core/cli.ts +333 -0
  54. package/src/core/db.ts +53 -0
  55. package/src/core/event-bus.ts +33 -0
  56. package/src/core/index.ts +6 -0
  57. package/src/core/migrations.ts +303 -0
  58. package/src/core/router.ts +69 -0
  59. package/src/core/schema.sql +232 -0
  60. package/src/core/server.ts +308 -0
  61. package/src/core/validate.ts +22 -0
  62. package/src/discovery/index.ts +194 -0
  63. package/src/gateway/adapters/telegram.ts +148 -0
  64. package/src/gateway/index.ts +31 -0
  65. package/src/gateway/manager.ts +176 -0
  66. package/src/gateway/types.ts +77 -0
  67. package/src/inbox/index.ts +500 -0
  68. package/src/ops/artifact-sync.ts +65 -0
  69. package/src/ops/autopilot.ts +338 -0
  70. package/src/ops/gc.ts +252 -0
  71. package/src/ops/index.ts +226 -0
  72. package/src/ops/project-serial.ts +52 -0
  73. package/src/ops/role-dispatch.ts +111 -0
  74. package/src/ops/runtime-scheduler.ts +447 -0
  75. package/src/ops/task-blocking.ts +65 -0
  76. package/src/ops/task-deps.ts +37 -0
  77. package/src/ops/task-workspace.ts +60 -0
  78. package/src/roles/index.ts +258 -0
  79. package/src/roles/prompt-assembler.ts +85 -0
  80. package/src/roles/workspace-role.ts +155 -0
  81. package/src/scheduler/index.ts +461 -0
  82. package/src/session/output-parser.ts +75 -0
  83. package/src/session/realtime-parser.ts +40 -0
  84. package/src/skills/builtin.ts +155 -0
  85. package/src/skills/skill-extractor.ts +452 -0
  86. package/src/skills/skill-md.ts +282 -0
  87. package/src/transport/http-api.ts +75 -0
  88. package/src/transport/index.ts +4 -0
  89. package/src/transport/local-pty.ts +119 -0
  90. package/src/transport/ssh.ts +176 -0
  91. package/src/transport/types.ts +20 -0
  92. package/src/workflows/index.ts +231 -0
  93. package/index.js +0 -1
  94. 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,4 @@
1
+ export { LocalPtyTransport } from "./local-pty";
2
+ export { SshTransport } from "./ssh";
3
+ export { HttpApiTransport } from "./http-api";
4
+ export type { Transport, TransportProcess, TransportSpawnOptions } from "./types";
@@ -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
+ }