@pipeline-studio/local-agent 0.1.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/bin/psa.js +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +665 -0
- package/package.json +53 -0
package/bin/psa.js
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
// src/commands/connect.ts
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
var CONFIG_DIR = path.join(os.homedir(), ".pipeline-studio");
|
|
12
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
13
|
+
var PID_FILE = path.join(CONFIG_DIR, "agent.pid");
|
|
14
|
+
function getConfigPath() {
|
|
15
|
+
return CONFIG_FILE;
|
|
16
|
+
}
|
|
17
|
+
function loadConfig() {
|
|
18
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function saveConfig(config) {
|
|
27
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
28
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
29
|
+
fs.chmodSync(CONFIG_DIR, 448);
|
|
30
|
+
}
|
|
31
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
32
|
+
fs.chmodSync(CONFIG_FILE, 384);
|
|
33
|
+
}
|
|
34
|
+
function savePid(pid) {
|
|
35
|
+
fs.writeFileSync(PID_FILE, String(pid));
|
|
36
|
+
}
|
|
37
|
+
function readPid() {
|
|
38
|
+
if (!fs.existsSync(PID_FILE)) return null;
|
|
39
|
+
try {
|
|
40
|
+
return parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function clearPid() {
|
|
46
|
+
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
47
|
+
}
|
|
48
|
+
function isProcessAlive(pid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/commands/connect.ts
|
|
58
|
+
async function connectCommand(cloudUrl) {
|
|
59
|
+
const chalk = (await import("chalk")).default;
|
|
60
|
+
const ora = (await import("ora")).default;
|
|
61
|
+
const open = (await import("open")).default;
|
|
62
|
+
const url = cloudUrl.replace(/\/$/, "");
|
|
63
|
+
const deviceCode = randomUUID().slice(0, 8);
|
|
64
|
+
console.log(chalk.bold("\nPipeline Studio Agent \u2014 Connect\n"));
|
|
65
|
+
console.log(chalk.dim(`Cloud URL: ${url}`));
|
|
66
|
+
console.log(chalk.dim(`Device code: ${deviceCode}
|
|
67
|
+
`));
|
|
68
|
+
const authUrl = `${url}/agent/authorize?code=${deviceCode}`;
|
|
69
|
+
console.log(chalk.cyan(`Opening browser: ${authUrl}
|
|
70
|
+
`));
|
|
71
|
+
const spinner = ora("Waiting for authorization...").start();
|
|
72
|
+
try {
|
|
73
|
+
await open(authUrl);
|
|
74
|
+
} catch {
|
|
75
|
+
spinner.warn("Could not open browser automatically");
|
|
76
|
+
console.log(chalk.yellow(`Open this URL manually: ${authUrl}
|
|
77
|
+
`));
|
|
78
|
+
}
|
|
79
|
+
const maxAttempts = 60;
|
|
80
|
+
const pollInterval = 3e3;
|
|
81
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
82
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${url}/api/agent/register`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ deviceCode })
|
|
88
|
+
});
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
const data = await res.json();
|
|
91
|
+
const config = {
|
|
92
|
+
cloudUrl: url,
|
|
93
|
+
token: data.token,
|
|
94
|
+
agentId: data.agentId,
|
|
95
|
+
projects: []
|
|
96
|
+
};
|
|
97
|
+
saveConfig(config);
|
|
98
|
+
spinner.succeed(chalk.green("Connected successfully!"));
|
|
99
|
+
console.log(chalk.dim(`
|
|
100
|
+
Config saved to: ${getConfigPath()}`));
|
|
101
|
+
console.log(chalk.dim(`Agent ID: ${data.agentId}`));
|
|
102
|
+
console.log(chalk.bold("\nNext step: run ") + chalk.cyan("psa start"));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (res.status === 202) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (res.status >= 400) {
|
|
109
|
+
spinner.fail(chalk.red(`Authorization failed: ${res.status}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
spinner.fail(chalk.red("Authorization timed out. Please try again."));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/client/cloud-client.ts
|
|
119
|
+
var CloudClient = class {
|
|
120
|
+
baseUrl;
|
|
121
|
+
token;
|
|
122
|
+
agentId;
|
|
123
|
+
constructor(config) {
|
|
124
|
+
this.baseUrl = config.cloudUrl.replace(/\/$/, "");
|
|
125
|
+
this.token = config.token;
|
|
126
|
+
this.agentId = config.agentId;
|
|
127
|
+
}
|
|
128
|
+
async request(path3, options = {}) {
|
|
129
|
+
const url = `${this.baseUrl}${path3}`;
|
|
130
|
+
const res = await fetch(url, {
|
|
131
|
+
...options,
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
"Authorization": `Bearer ${this.token}`,
|
|
135
|
+
"X-Agent-Id": this.agentId,
|
|
136
|
+
...options.headers
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const body = await res.text().catch(() => "");
|
|
141
|
+
throw new Error(`API ${res.status}: ${body}`);
|
|
142
|
+
}
|
|
143
|
+
return res.json();
|
|
144
|
+
}
|
|
145
|
+
async heartbeat(runningPipelines) {
|
|
146
|
+
await this.request("/api/agent/heartbeat", {
|
|
147
|
+
method: "POST",
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
agentId: this.agentId,
|
|
150
|
+
runningPipelines,
|
|
151
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
152
|
+
})
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async pollCommands() {
|
|
156
|
+
return this.request("/api/agent/commands");
|
|
157
|
+
}
|
|
158
|
+
async ackCommand(commandId) {
|
|
159
|
+
await this.request(`/api/agent/commands/${commandId}/ack`, {
|
|
160
|
+
method: "POST"
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async sendEvents(events) {
|
|
164
|
+
if (events.length === 0) return;
|
|
165
|
+
await this.request("/api/agent/events", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify({ events })
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async sendEvent(event) {
|
|
171
|
+
await this.sendEvents([event]);
|
|
172
|
+
}
|
|
173
|
+
async register(projects) {
|
|
174
|
+
return this.request("/api/agent/register", {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: JSON.stringify({ projects })
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// src/client/polling.ts
|
|
182
|
+
var CommandPoller = class {
|
|
183
|
+
client;
|
|
184
|
+
handler;
|
|
185
|
+
interval = null;
|
|
186
|
+
heartbeatInterval = null;
|
|
187
|
+
running = false;
|
|
188
|
+
getRunningPipelines;
|
|
189
|
+
constructor(client, handler, getRunningPipelines) {
|
|
190
|
+
this.client = client;
|
|
191
|
+
this.handler = handler;
|
|
192
|
+
this.getRunningPipelines = getRunningPipelines;
|
|
193
|
+
}
|
|
194
|
+
start(pollMs = 5e3, heartbeatMs = 3e4) {
|
|
195
|
+
if (this.running) return;
|
|
196
|
+
this.running = true;
|
|
197
|
+
this.poll();
|
|
198
|
+
this.interval = setInterval(() => this.poll(), pollMs);
|
|
199
|
+
this.sendHeartbeat();
|
|
200
|
+
this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), heartbeatMs);
|
|
201
|
+
}
|
|
202
|
+
stop() {
|
|
203
|
+
this.running = false;
|
|
204
|
+
if (this.interval) {
|
|
205
|
+
clearInterval(this.interval);
|
|
206
|
+
this.interval = null;
|
|
207
|
+
}
|
|
208
|
+
if (this.heartbeatInterval) {
|
|
209
|
+
clearInterval(this.heartbeatInterval);
|
|
210
|
+
this.heartbeatInterval = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async poll() {
|
|
214
|
+
if (!this.running) return;
|
|
215
|
+
try {
|
|
216
|
+
const commands = await this.client.pollCommands();
|
|
217
|
+
for (const cmd of commands) {
|
|
218
|
+
await this.client.ackCommand(cmd.id);
|
|
219
|
+
await this.handler(cmd);
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async sendHeartbeat() {
|
|
225
|
+
if (!this.running) return;
|
|
226
|
+
try {
|
|
227
|
+
await this.client.heartbeat(this.getRunningPipelines());
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/runner/pipeline-runner.ts
|
|
234
|
+
import { spawn } from "child_process";
|
|
235
|
+
import { EventEmitter } from "events";
|
|
236
|
+
var MAX_TOKENS_BY_MODEL = {
|
|
237
|
+
"claude-opus-4-6": 2e5,
|
|
238
|
+
"claude-sonnet-4-6": 2e5,
|
|
239
|
+
"claude-sonnet-4-5-20250929": 2e5,
|
|
240
|
+
"claude-haiku-4-5-20251001": 2e5
|
|
241
|
+
};
|
|
242
|
+
var CONTEXT_WARN_THRESHOLD = 0.6;
|
|
243
|
+
var PipelineRunner = class extends EventEmitter {
|
|
244
|
+
processes = /* @__PURE__ */ new Map();
|
|
245
|
+
run(options) {
|
|
246
|
+
const { projectPath, ticketKey, model = "claude-sonnet-4-6", maxTurns, manualTicketContext } = options;
|
|
247
|
+
if (this.processes.has(ticketKey)) {
|
|
248
|
+
const existing = this.processes.get(ticketKey);
|
|
249
|
+
if (this.isAlive(existing)) {
|
|
250
|
+
throw new Error(`Pipeline already running for ${ticketKey}`);
|
|
251
|
+
}
|
|
252
|
+
this.processes.delete(ticketKey);
|
|
253
|
+
}
|
|
254
|
+
const env = this.buildEnv(model);
|
|
255
|
+
const args = this.buildArgs(model, ticketKey, maxTurns, manualTicketContext);
|
|
256
|
+
const proc = spawn("claude", args, {
|
|
257
|
+
cwd: projectPath,
|
|
258
|
+
env,
|
|
259
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
260
|
+
});
|
|
261
|
+
this.processes.set(ticketKey, proc);
|
|
262
|
+
proc.stdout?.on("data", (chunk) => {
|
|
263
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
this.emit(ticketKey, { type: "log", data: line });
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(line);
|
|
268
|
+
if (parsed.type === "result") {
|
|
269
|
+
this.emit(ticketKey, {
|
|
270
|
+
type: "result",
|
|
271
|
+
resultSubtype: parsed.subtype ?? null,
|
|
272
|
+
totalCostUsd: parsed.total_cost_usd ?? 0,
|
|
273
|
+
durationMs: parsed.duration_ms ?? 0,
|
|
274
|
+
durationApiMs: parsed.duration_api_ms ?? 0,
|
|
275
|
+
numTurns: parsed.num_turns ?? 0,
|
|
276
|
+
inputTokens: parsed.usage?.input_tokens ?? 0,
|
|
277
|
+
outputTokens: parsed.usage?.output_tokens ?? 0,
|
|
278
|
+
cacheCreationTokens: parsed.usage?.cache_creation_input_tokens ?? 0,
|
|
279
|
+
cacheReadTokens: parsed.usage?.cache_read_input_tokens ?? 0
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
const tokenMatch = line.match(/"input_tokens":\s*(\d+)/);
|
|
285
|
+
if (tokenMatch) {
|
|
286
|
+
const inputTokens = parseInt(tokenMatch[1]);
|
|
287
|
+
const maxTokens = MAX_TOKENS_BY_MODEL[model] ?? 2e5;
|
|
288
|
+
const percent = inputTokens / maxTokens;
|
|
289
|
+
if (percent > CONTEXT_WARN_THRESHOLD) {
|
|
290
|
+
this.emit(ticketKey, {
|
|
291
|
+
type: "context_warning",
|
|
292
|
+
contextPercent: percent,
|
|
293
|
+
inputTokens,
|
|
294
|
+
maxTokens
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
proc.stderr?.on("data", (chunk) => {
|
|
301
|
+
this.emit(ticketKey, { type: "log", data: `[stderr] ${chunk.toString()}` });
|
|
302
|
+
});
|
|
303
|
+
proc.on("exit", (code) => {
|
|
304
|
+
this.processes.delete(ticketKey);
|
|
305
|
+
this.emit(ticketKey, { type: "exit", exitCode: code ?? 1 });
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
stop(ticketKey) {
|
|
309
|
+
const proc = this.processes.get(ticketKey);
|
|
310
|
+
if (proc) {
|
|
311
|
+
proc.kill("SIGTERM");
|
|
312
|
+
this.processes.delete(ticketKey);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
isRunning(ticketKey) {
|
|
316
|
+
const proc = this.processes.get(ticketKey);
|
|
317
|
+
if (!proc) return false;
|
|
318
|
+
if (!this.isAlive(proc)) {
|
|
319
|
+
this.processes.delete(ticketKey);
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
runningKeys() {
|
|
325
|
+
for (const [key, proc] of this.processes) {
|
|
326
|
+
if (!this.isAlive(proc)) this.processes.delete(key);
|
|
327
|
+
}
|
|
328
|
+
return [...this.processes.keys()];
|
|
329
|
+
}
|
|
330
|
+
isAlive(proc) {
|
|
331
|
+
if (proc.exitCode !== null || proc.killed) return false;
|
|
332
|
+
if (!proc.pid) return false;
|
|
333
|
+
try {
|
|
334
|
+
process.kill(proc.pid, 0);
|
|
335
|
+
return true;
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
buildEnv(model) {
|
|
341
|
+
const env = { ...process.env };
|
|
342
|
+
delete env["ANTHROPIC_API_KEY"];
|
|
343
|
+
delete env["CLAUDECODE"];
|
|
344
|
+
for (const key of Object.keys(env)) {
|
|
345
|
+
if (key.startsWith("CLAUDE_CODE_")) delete env[key];
|
|
346
|
+
}
|
|
347
|
+
env["CLAUDE_USE_SUBSCRIPTION"] = "true";
|
|
348
|
+
env["ARENA_MODEL"] = model;
|
|
349
|
+
return env;
|
|
350
|
+
}
|
|
351
|
+
buildArgs(model, ticketKey, maxTurns, manualTicketContext) {
|
|
352
|
+
const safeTurns = maxTurns && maxTurns > 0 ? maxTurns : 200;
|
|
353
|
+
const parts = [
|
|
354
|
+
`Execute o skill /pipeline para a task ${ticketKey}. Use a configuracao em .claude/pipeline-config.json para ordem de fases, agentes por fase e configuracao de gates. Rode todas as fases do pipeline ate a conclusao completa. Todos os reports devem ser escritos em .claude/reports/${ticketKey}/ dentro do diretorio de trabalho.`
|
|
355
|
+
];
|
|
356
|
+
if (manualTicketContext) {
|
|
357
|
+
parts.push("", manualTicketContext);
|
|
358
|
+
}
|
|
359
|
+
return [
|
|
360
|
+
"--print",
|
|
361
|
+
"--dangerously-skip-permissions",
|
|
362
|
+
"--output-format",
|
|
363
|
+
"stream-json",
|
|
364
|
+
"--verbose",
|
|
365
|
+
"--model",
|
|
366
|
+
model,
|
|
367
|
+
"--max-turns",
|
|
368
|
+
String(safeTurns),
|
|
369
|
+
parts.join("\n")
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// src/runner/file-watcher.ts
|
|
375
|
+
import chokidar from "chokidar";
|
|
376
|
+
import fs2 from "fs";
|
|
377
|
+
import path2 from "path";
|
|
378
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
379
|
+
var DEFAULT_PHASE_MAP = {
|
|
380
|
+
"ticket.json": "1-READ",
|
|
381
|
+
"architecture-report.md": "2-PLAN",
|
|
382
|
+
"implementation-manifest.md": "3-IMPLEMENT",
|
|
383
|
+
"validation-report.md": "4-VALIDATE",
|
|
384
|
+
"test-report.md": "5-TEST",
|
|
385
|
+
"e2e-report.md": "6-E2E",
|
|
386
|
+
"review-report.md": "7-REVIEW",
|
|
387
|
+
"pr-report.md": "9-PR",
|
|
388
|
+
"ci-report.md": "10-CI",
|
|
389
|
+
"deploy-report.md": "11-DEPLOY"
|
|
390
|
+
};
|
|
391
|
+
var PhaseWatcher = class extends EventEmitter2 {
|
|
392
|
+
watchers = /* @__PURE__ */ new Map();
|
|
393
|
+
emittedPhases = /* @__PURE__ */ new Map();
|
|
394
|
+
watch(projectPath, ticketId) {
|
|
395
|
+
if (this.watchers.has(ticketId)) this.unwatch(ticketId);
|
|
396
|
+
const reportsDir = path2.join(projectPath, ".claude", "reports", ticketId);
|
|
397
|
+
if (!fs2.existsSync(reportsDir)) {
|
|
398
|
+
fs2.mkdirSync(reportsDir, { recursive: true });
|
|
399
|
+
}
|
|
400
|
+
this.emittedPhases.set(ticketId, /* @__PURE__ */ new Set());
|
|
401
|
+
const watcher = chokidar.watch(reportsDir, {
|
|
402
|
+
persistent: true,
|
|
403
|
+
ignoreInitial: false,
|
|
404
|
+
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }
|
|
405
|
+
});
|
|
406
|
+
watcher.on("add", (filepath) => this.handleFile(filepath, ticketId));
|
|
407
|
+
watcher.on("change", (filepath) => this.handleFile(filepath, ticketId));
|
|
408
|
+
this.watchers.set(ticketId, watcher);
|
|
409
|
+
}
|
|
410
|
+
unwatch(ticketId) {
|
|
411
|
+
const watcher = this.watchers.get(ticketId);
|
|
412
|
+
if (watcher) {
|
|
413
|
+
watcher.close();
|
|
414
|
+
this.watchers.delete(ticketId);
|
|
415
|
+
}
|
|
416
|
+
this.emittedPhases.delete(ticketId);
|
|
417
|
+
}
|
|
418
|
+
unwatchAll() {
|
|
419
|
+
for (const ticketId of this.watchers.keys()) {
|
|
420
|
+
this.unwatch(ticketId);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
handleFile(filepath, ticketId) {
|
|
424
|
+
const filename = path2.basename(filepath);
|
|
425
|
+
const phase = DEFAULT_PHASE_MAP[filename];
|
|
426
|
+
if (phase) {
|
|
427
|
+
const emitted = this.emittedPhases.get(ticketId);
|
|
428
|
+
if (emitted?.has(phase)) return;
|
|
429
|
+
emitted?.add(phase);
|
|
430
|
+
this.emit("event", {
|
|
431
|
+
type: "phase_complete",
|
|
432
|
+
ticketId,
|
|
433
|
+
phase,
|
|
434
|
+
reportPath: filepath
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (/^human-task-\d+\.json$/.test(filename)) {
|
|
439
|
+
try {
|
|
440
|
+
const raw = fs2.readFileSync(filepath, "utf-8");
|
|
441
|
+
const task = JSON.parse(raw);
|
|
442
|
+
this.emit("event", {
|
|
443
|
+
type: "human_task_created",
|
|
444
|
+
ticketId,
|
|
445
|
+
humanTask: task
|
|
446
|
+
});
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// src/runner/event-reporter.ts
|
|
454
|
+
var EventReporter = class {
|
|
455
|
+
client;
|
|
456
|
+
buffer = [];
|
|
457
|
+
flushTimer = null;
|
|
458
|
+
constructor(client, flushIntervalMs = 2e3) {
|
|
459
|
+
this.client = client;
|
|
460
|
+
this.flushTimer = setInterval(() => this.flush(), flushIntervalMs);
|
|
461
|
+
}
|
|
462
|
+
reportPipelineEvent(ticketKey, runId, event) {
|
|
463
|
+
this.buffer.push({
|
|
464
|
+
type: event.type,
|
|
465
|
+
ticketKey,
|
|
466
|
+
runId,
|
|
467
|
+
data: event.data,
|
|
468
|
+
phase: event.phase,
|
|
469
|
+
exitCode: event.exitCode,
|
|
470
|
+
resultSubtype: event.resultSubtype,
|
|
471
|
+
totalCostUsd: event.totalCostUsd,
|
|
472
|
+
durationMs: event.durationMs,
|
|
473
|
+
numTurns: event.numTurns,
|
|
474
|
+
inputTokens: event.inputTokens,
|
|
475
|
+
outputTokens: event.outputTokens,
|
|
476
|
+
cacheCreationTokens: event.cacheCreationTokens,
|
|
477
|
+
cacheReadTokens: event.cacheReadTokens,
|
|
478
|
+
contextPercent: event.contextPercent
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
reportPhaseEvent(ticketKey, runId, event) {
|
|
482
|
+
if (event.type === "phase_complete") {
|
|
483
|
+
this.buffer.push({
|
|
484
|
+
type: "phase_detected",
|
|
485
|
+
ticketKey,
|
|
486
|
+
runId,
|
|
487
|
+
phase: event.phase,
|
|
488
|
+
data: event.reportPath
|
|
489
|
+
});
|
|
490
|
+
} else if (event.type === "human_task_created") {
|
|
491
|
+
this.buffer.push({
|
|
492
|
+
type: "human_task",
|
|
493
|
+
ticketKey,
|
|
494
|
+
runId,
|
|
495
|
+
data: JSON.stringify(event.humanTask)
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async flush() {
|
|
500
|
+
if (this.buffer.length === 0) return;
|
|
501
|
+
const events = [...this.buffer];
|
|
502
|
+
this.buffer = [];
|
|
503
|
+
try {
|
|
504
|
+
await this.client.sendEvents(events);
|
|
505
|
+
} catch {
|
|
506
|
+
this.buffer.unshift(...events);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
stop() {
|
|
510
|
+
if (this.flushTimer) {
|
|
511
|
+
clearInterval(this.flushTimer);
|
|
512
|
+
this.flushTimer = null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// src/commands/start.ts
|
|
518
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
519
|
+
async function startCommand(foreground) {
|
|
520
|
+
const chalk = (await import("chalk")).default;
|
|
521
|
+
const config = loadConfig();
|
|
522
|
+
if (!config) {
|
|
523
|
+
console.log(chalk.red("Not connected. Run: psa connect <cloud-url>"));
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
console.log(chalk.bold("\nPipeline Studio Agent\n"));
|
|
527
|
+
console.log(chalk.dim(`Cloud: ${config.cloudUrl}`));
|
|
528
|
+
console.log(chalk.dim(`Agent: ${config.agentId}`));
|
|
529
|
+
console.log(chalk.dim(`Mode: ${foreground ? "foreground" : "daemon"}
|
|
530
|
+
`));
|
|
531
|
+
savePid(process.pid);
|
|
532
|
+
const client = new CloudClient(config);
|
|
533
|
+
const runner = new PipelineRunner();
|
|
534
|
+
const watcher = new PhaseWatcher();
|
|
535
|
+
const reporter = new EventReporter(client);
|
|
536
|
+
const runIds = /* @__PURE__ */ new Map();
|
|
537
|
+
async function handleCommand(cmd) {
|
|
538
|
+
if (cmd.type === "start_pipeline") {
|
|
539
|
+
const runId = randomUUID2();
|
|
540
|
+
runIds.set(cmd.ticketKey, runId);
|
|
541
|
+
console.log(chalk.cyan(`[${cmd.ticketKey}] Starting pipeline...`));
|
|
542
|
+
runner.on(cmd.ticketKey, (event) => {
|
|
543
|
+
reporter.reportPipelineEvent(cmd.ticketKey, runId, event);
|
|
544
|
+
if (event.type === "exit") {
|
|
545
|
+
console.log(chalk.dim(`[${cmd.ticketKey}] Pipeline exited (code ${event.exitCode})`));
|
|
546
|
+
watcher.unwatch(cmd.ticketKey);
|
|
547
|
+
runIds.delete(cmd.ticketKey);
|
|
548
|
+
reporter.flush();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
watcher.on("event", (event) => {
|
|
552
|
+
if (event.ticketId === cmd.ticketKey) {
|
|
553
|
+
reporter.reportPhaseEvent(cmd.ticketKey, runId, event);
|
|
554
|
+
if (event.phase) {
|
|
555
|
+
console.log(chalk.green(`[${cmd.ticketKey}] Phase: ${event.phase}`));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
watcher.watch(cmd.projectPath, cmd.ticketKey);
|
|
560
|
+
runner.run({
|
|
561
|
+
projectPath: cmd.projectPath,
|
|
562
|
+
ticketKey: cmd.ticketKey,
|
|
563
|
+
model: cmd.model,
|
|
564
|
+
maxTurns: cmd.maxTurns,
|
|
565
|
+
manualTicketContext: cmd.manualTicketContext
|
|
566
|
+
});
|
|
567
|
+
} else if (cmd.type === "stop_pipeline") {
|
|
568
|
+
console.log(chalk.yellow(`[${cmd.ticketKey}] Stopping pipeline...`));
|
|
569
|
+
runner.stop(cmd.ticketKey);
|
|
570
|
+
watcher.unwatch(cmd.ticketKey);
|
|
571
|
+
runIds.delete(cmd.ticketKey);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const poller = new CommandPoller(
|
|
575
|
+
client,
|
|
576
|
+
handleCommand,
|
|
577
|
+
() => runner.runningKeys()
|
|
578
|
+
);
|
|
579
|
+
poller.start();
|
|
580
|
+
console.log(chalk.green("Agent started. Listening for commands...\n"));
|
|
581
|
+
const shutdown = () => {
|
|
582
|
+
console.log(chalk.yellow("\nShutting down..."));
|
|
583
|
+
poller.stop();
|
|
584
|
+
reporter.stop();
|
|
585
|
+
watcher.unwatchAll();
|
|
586
|
+
for (const key of runner.runningKeys()) {
|
|
587
|
+
runner.stop(key);
|
|
588
|
+
}
|
|
589
|
+
clearPid();
|
|
590
|
+
process.exit(0);
|
|
591
|
+
};
|
|
592
|
+
process.on("SIGINT", shutdown);
|
|
593
|
+
process.on("SIGTERM", shutdown);
|
|
594
|
+
if (foreground) {
|
|
595
|
+
await new Promise(() => {
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/commands/stop.ts
|
|
601
|
+
async function stopCommand() {
|
|
602
|
+
const chalk = (await import("chalk")).default;
|
|
603
|
+
const pid = readPid();
|
|
604
|
+
if (!pid) {
|
|
605
|
+
console.log(chalk.yellow("No agent process found."));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (!isProcessAlive(pid)) {
|
|
609
|
+
console.log(chalk.yellow("Agent process is not running. Cleaning up..."));
|
|
610
|
+
clearPid();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
process.kill(pid, "SIGTERM");
|
|
615
|
+
console.log(chalk.green(`Agent stopped (PID: ${pid})`));
|
|
616
|
+
clearPid();
|
|
617
|
+
} catch {
|
|
618
|
+
console.log(chalk.red("Failed to stop agent process."));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/commands/status.ts
|
|
623
|
+
async function statusCommand() {
|
|
624
|
+
const chalk = (await import("chalk")).default;
|
|
625
|
+
const config = loadConfig();
|
|
626
|
+
if (!config) {
|
|
627
|
+
console.log(chalk.red("Not connected. Run: psa connect <cloud-url>"));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
console.log(chalk.bold("\nPipeline Studio Agent Status\n"));
|
|
631
|
+
console.log(` Cloud URL: ${chalk.cyan(config.cloudUrl)}`);
|
|
632
|
+
console.log(` Agent ID: ${chalk.dim(config.agentId)}`);
|
|
633
|
+
console.log(` Config: ${chalk.dim(getConfigPath())}`);
|
|
634
|
+
const pid = readPid();
|
|
635
|
+
if (pid && isProcessAlive(pid)) {
|
|
636
|
+
console.log(` Status: ${chalk.green("Online")} (PID: ${pid})`);
|
|
637
|
+
} else {
|
|
638
|
+
console.log(` Status: ${chalk.yellow("Offline")}`);
|
|
639
|
+
}
|
|
640
|
+
if (config.projects.length > 0) {
|
|
641
|
+
console.log(`
|
|
642
|
+
Projects:`);
|
|
643
|
+
for (const p of config.projects) {
|
|
644
|
+
console.log(` ${chalk.dim("-")} ${p}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
console.log("");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/cli.ts
|
|
651
|
+
var program = new Command();
|
|
652
|
+
program.name("psa").description("Pipeline Studio Agent \u2014 Run AI engineering pipelines locally, connected to the cloud dashboard").version("0.1.0");
|
|
653
|
+
program.command("connect <cloud-url>").description("Connect this machine to a Pipeline Studio cloud instance").action(async (cloudUrl) => {
|
|
654
|
+
await connectCommand(cloudUrl);
|
|
655
|
+
});
|
|
656
|
+
program.command("start").description("Start the agent daemon \u2014 listens for pipeline commands from the cloud").option("-f, --foreground", "Run in foreground instead of as daemon", false).action(async (opts) => {
|
|
657
|
+
await startCommand(opts.foreground);
|
|
658
|
+
});
|
|
659
|
+
program.command("stop").description("Stop the running agent daemon").action(async () => {
|
|
660
|
+
await stopCommand();
|
|
661
|
+
});
|
|
662
|
+
program.command("status").description("Show agent connection status").action(async () => {
|
|
663
|
+
await statusCommand();
|
|
664
|
+
});
|
|
665
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pipeline-studio/local-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local agent for Pipeline Studio — runs Claude CLI pipelines and syncs with cloud dashboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cli.js",
|
|
7
|
+
"types": "./dist/cli.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"psa": "./bin/psa.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsup src/cli.ts --format esm --dts --outDir dist",
|
|
13
|
+
"dev": "tsup src/cli.ts --format esm --watch",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/felipemlanna1/pipeline-studio.git",
|
|
20
|
+
"directory": "packages/local-agent"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/felipemlanna1/pipeline-studio#readme",
|
|
23
|
+
"author": "Felipe Moreira Lanna <felipemlanna@gmail.com>",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"chalk": "^5.3.0",
|
|
26
|
+
"chokidar": "^4.0.3",
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"open": "^10.1.0",
|
|
29
|
+
"ora": "^8.1.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.10.0",
|
|
33
|
+
"tsup": "^8.3.0",
|
|
34
|
+
"typescript": "^5.7.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"bin"
|
|
42
|
+
],
|
|
43
|
+
"keywords": [
|
|
44
|
+
"pipeline-studio",
|
|
45
|
+
"claude",
|
|
46
|
+
"ai",
|
|
47
|
+
"agent",
|
|
48
|
+
"cli",
|
|
49
|
+
"pipeline",
|
|
50
|
+
"automation"
|
|
51
|
+
],
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|