@sweny-ai/core 0.1.0 → 0.1.1

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/dist/claude.d.ts CHANGED
@@ -36,6 +36,7 @@ export declare class ClaudeClient implements Claude {
36
36
  context: Record<string, unknown>;
37
37
  tools: Tool[];
38
38
  outputSchema?: JSONSchema;
39
+ onProgress?: (message: string) => void;
39
40
  }): Promise<NodeResult>;
40
41
  evaluate(opts: {
41
42
  question: string;
package/dist/claude.js CHANGED
@@ -28,7 +28,7 @@ export class ClaudeClient {
28
28
  this.mcpServers = opts.mcpServers ?? {};
29
29
  }
30
30
  async run(opts) {
31
- const { instruction, context, tools, outputSchema } = opts;
31
+ const { instruction, context, tools, outputSchema, onProgress } = opts;
32
32
  const toolCalls = [];
33
33
  // Convert core tools to SDK MCP tools
34
34
  const sdkTools = tools.map((t) => coreToolToSdkTool(t, this.defaultContext, toolCalls));
@@ -67,7 +67,22 @@ export class ClaudeClient {
67
67
  },
68
68
  });
69
69
  for await (const message of stream) {
70
- if (message.type === "result") {
70
+ if (message.type === "tool_progress") {
71
+ const tp = message;
72
+ if (tp.tool_name && typeof tp.elapsed_time_seconds === "number") {
73
+ const name = stripMcpPrefix(tp.tool_name);
74
+ const secs = Math.round(tp.elapsed_time_seconds);
75
+ onProgress?.(`${name} (${secs}s)`);
76
+ }
77
+ }
78
+ else if (message.type === "tool_use_summary") {
79
+ const ts = message;
80
+ if (ts.summary) {
81
+ const clean = ts.summary.replace(/\n/g, " ").trim();
82
+ onProgress?.(clean.length > 80 ? clean.slice(0, 79) + "\u2026" : clean);
83
+ }
84
+ }
85
+ else if (message.type === "result") {
71
86
  const resultMsg = message;
72
87
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
73
88
  response = resultMsg.result;
@@ -233,6 +248,13 @@ function jsonPropertyToZod(prop) {
233
248
  }
234
249
  }
235
250
  }
251
+ /** Strip MCP server prefix: "mcp__server__tool" → "tool" */
252
+ function stripMcpPrefix(name) {
253
+ const parts = name.split("__");
254
+ if (parts.length >= 3 && parts[0] === "mcp")
255
+ return parts.slice(2).join("__");
256
+ return name;
257
+ }
236
258
  // ─── JSON extraction ────────────────────────────────────────────
237
259
  /**
238
260
  * Extract a JSON object from Claude's text response.
@@ -49,6 +49,7 @@ export interface CliConfig {
49
49
  repository: string;
50
50
  repositoryOwner: string;
51
51
  json: boolean;
52
+ stream: boolean;
52
53
  bell: boolean;
53
54
  cacheDir: string;
54
55
  cacheTtl: number;
@@ -55,6 +55,7 @@ export function registerTriageCommand(program) {
55
55
  .option("--betterstack-table-name <name>", 'Better Stack ClickHouse table name, e.g. "t273774.my_source"')
56
56
  .option("--gitlab-base-url <url>", "GitLab base URL (default: https://gitlab.com)")
57
57
  .option("--json", "Output results as JSON", false)
58
+ .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)", false)
58
59
  .option("--bell", "Ring terminal bell on completion", false)
59
60
  .option("--cache-dir <path>", "Step cache directory (default: .sweny/cache)")
60
61
  .option("--cache-ttl <seconds>", "Cache TTL in seconds, 0 = infinite (default: 86400)")
@@ -126,6 +127,7 @@ export function parseCliInputs(options, fileConfig = {}) {
126
127
  repositoryOwner: env.GITHUB_REPOSITORY_OWNER || "",
127
128
  // Per-invocation flags: CLI only
128
129
  json: Boolean(options.json),
130
+ stream: Boolean(options.stream),
129
131
  bell: Boolean(options.bell),
130
132
  cacheDir: options.cacheDir || env.SWENY_CACHE_DIR || f("cache-dir") || ".sweny/cache",
131
133
  cacheTtl: parseInt(String(options.cacheTtl || f("cache-ttl") || "86400"), 10),
@@ -528,7 +530,8 @@ export function registerImplementCommand(program) {
528
530
  .option("--output-dir <path>", "Output directory for file providers (default: .sweny/output)")
529
531
  .option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)")
530
532
  .option("--review-mode <mode>", "PR merge behavior: auto (GitHub auto-merge when CI passes) | review (human approval, default)", "review")
531
- .option("--additional-instructions <text>", "Extra instructions for the coding agent");
533
+ .option("--additional-instructions <text>", "Extra instructions for the coding agent")
534
+ .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)", false);
532
535
  }
533
536
  /**
534
537
  * Parse a JSON string into MCP server configs.
@@ -3,6 +3,7 @@ import type { Workflow } from "../types.js";
3
3
  export declare function loadWorkflowFile(filePath: string): Workflow;
4
4
  export declare function workflowRunAction(file: string, options: Record<string, unknown> & {
5
5
  json?: boolean;
6
+ stream?: boolean;
6
7
  }): Promise<void>;
7
8
  export declare function workflowExportAction(name: string): void;
8
9
  export declare function workflowValidateAction(file: string, options: {
package/dist/cli/main.js CHANGED
@@ -22,6 +22,28 @@ import { registerTriageCommand, registerImplementCommand, parseCliInputs, valida
22
22
  import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
23
23
  import { checkProviderConnectivity } from "./check.js";
24
24
  import { registerSetupCommand } from "./setup.js";
25
+ // ── Stream observer (NDJSON) ────────────────────────────────────────
26
+ /**
27
+ * Create an observer that writes NDJSON ExecutionEvents to stdout.
28
+ * Studio and other consumers parse these line-by-line.
29
+ */
30
+ function createStreamObserver() {
31
+ return (event) => {
32
+ process.stdout.write(JSON.stringify(event) + "\n");
33
+ };
34
+ }
35
+ /** Compose multiple observers into one. */
36
+ function composeObservers(...observers) {
37
+ const valid = observers.filter((o) => o != null);
38
+ if (valid.length === 0)
39
+ return undefined;
40
+ if (valid.length === 1)
41
+ return valid[0];
42
+ return (event) => {
43
+ for (const o of valid)
44
+ o(event);
45
+ };
46
+ }
25
47
  // Auto-load .env before Commander parses (so env vars are available for defaults)
26
48
  loadDotenv();
27
49
  const program = new Command()
@@ -137,16 +159,17 @@ triageCmd.action(async (options) => {
137
159
  cwd: process.cwd(),
138
160
  logger: consoleLogger,
139
161
  });
140
- // ── Spinner state ──────────────────────────────────────────
162
+ // ── Progress display state ─────────────────────────────────
141
163
  const FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
142
164
  const isTTY = !config.json && (process.stderr.isTTY ?? false);
165
+ const MAX_ACTIVITY = 3;
143
166
  let spinnerInterval;
144
167
  let spinnerActive = false;
145
168
  let frameIdx = 0;
146
169
  let stepStart = 0;
147
170
  let stepLabel = "";
148
- let spinnerStatus = "";
149
- let currentPhaseColor = chalk.cyan;
171
+ let recentActivity = [];
172
+ let renderedLines = 0; // how many lines the progress block currently occupies
150
173
  let stepIndex = 0;
151
174
  const totalNodes = Object.keys(triageWorkflow.nodes).length;
152
175
  function formatElapsed(ms) {
@@ -156,33 +179,38 @@ triageCmd.action(async (options) => {
156
179
  const m = Math.floor(s / 60);
157
180
  return `${m}m ${s % 60}s`;
158
181
  }
159
- function clearSpinnerLine() {
160
- if (spinnerActive && isTTY) {
161
- process.stderr.write("\r\x1b[K");
182
+ /** Render the multi-line progress block (spinner + activity lines). */
183
+ function renderProgressBlock() {
184
+ const cols = process.stderr.columns || 80;
185
+ const frame = chalk.cyan(FRAMES[frameIdx++ % FRAMES.length]);
186
+ const counter = c.subtle(`[${stepIndex}/${totalNodes}]`);
187
+ const elapsed = c.subtle(formatElapsed(Date.now() - stepStart));
188
+ const headerLine = ` ${frame} ${counter} ${stepLabel} ${elapsed}`;
189
+ const lines = [headerLine];
190
+ for (const msg of recentActivity) {
191
+ // Truncate to terminal width
192
+ const line = ` ${c.subtle("\u21B3")} ${c.subtle(msg)}`;
193
+ const vis = line.replace(/\x1B\[[0-9;]*m/g, "").length;
194
+ lines.push(vis > cols ? line.slice(0, cols - 1) : line);
195
+ }
196
+ // Move cursor up to clear previous render, then clear to end of screen
197
+ if (renderedLines > 0) {
198
+ process.stderr.write(`\x1B[${renderedLines}A\x1B[J`);
162
199
  }
200
+ process.stderr.write(lines.join("\n") + "\n");
201
+ renderedLines = lines.length;
163
202
  }
164
203
  function startSpinner(label) {
165
204
  stepStart = Date.now();
166
205
  stepLabel = label;
167
- spinnerStatus = "";
206
+ recentActivity = [];
168
207
  frameIdx = 0;
169
208
  spinnerActive = true;
209
+ renderedLines = 0;
170
210
  if (isTTY) {
171
- const cols = process.stderr.columns || 80;
172
- spinnerInterval = setInterval(() => {
173
- const frame = currentPhaseColor(FRAMES[frameIdx++ % FRAMES.length]);
174
- const counter = c.subtle(`[${stepIndex}/${totalNodes}]`);
175
- const elapsed = c.subtle(formatElapsed(Date.now() - stepStart));
176
- const status = spinnerStatus ? ` ${c.subtle("\u2014")} ${c.subtle(spinnerStatus)}` : "";
177
- // Truncate to terminal width to prevent line wrapping
178
- let line = ` ${frame} ${counter} ${stepLabel}${status} ${elapsed}`;
179
- const visibleLen = line.replace(/\x1B\[[0-9;]*m/g, "").length;
180
- if (visibleLen > cols) {
181
- // Re-render without status if too wide
182
- line = ` ${frame} ${counter} ${stepLabel} ${elapsed}`;
183
- }
184
- process.stderr.write(`\r\x1b[K${line}`);
185
- }, 80);
211
+ process.stderr.write("\x1B[?25l"); // hide cursor
212
+ renderProgressBlock();
213
+ spinnerInterval = setInterval(() => renderProgressBlock(), 100);
186
214
  }
187
215
  else if (!config.json) {
188
216
  spinnerInterval = setInterval(() => {
@@ -196,59 +224,59 @@ triageCmd.action(async (options) => {
196
224
  clearInterval(spinnerInterval);
197
225
  spinnerInterval = undefined;
198
226
  }
199
- if (isTTY) {
200
- process.stderr.write("\r\x1b[K");
227
+ if (isTTY && renderedLines > 0) {
228
+ process.stderr.write(`\x1B[${renderedLines}A\x1B[J`);
229
+ process.stderr.write("\x1B[?25h"); // show cursor
230
+ renderedLines = 0;
201
231
  }
202
232
  spinnerActive = false;
203
233
  }
204
234
  // ── Build observer for DAG events ──────────────────────────
205
235
  const runStart = Date.now();
206
- const observer = config.json
236
+ const progressObserver = config.json
207
237
  ? undefined
208
238
  : (event) => {
209
239
  switch (event.type) {
210
240
  case "workflow:start":
211
- // Already printed the banner
212
241
  break;
213
242
  case "node:enter":
214
243
  stepIndex++;
215
- if (!config.json) {
216
- startSpinner(event.node);
244
+ startSpinner(event.node);
245
+ break;
246
+ case "node:progress":
247
+ if (spinnerActive) {
248
+ recentActivity.push(event.message);
249
+ if (recentActivity.length > MAX_ACTIVITY)
250
+ recentActivity.shift();
217
251
  }
218
252
  break;
219
253
  case "tool:call":
220
- if (spinnerActive && isTTY) {
221
- spinnerStatus = `${event.tool}(...)`;
222
- }
254
+ // tool:call is now superseded by the richer node:progress events
223
255
  break;
224
256
  case "node:exit": {
225
257
  stopSpinner();
226
- if (!config.json) {
227
- const elapsed = formatElapsed(Date.now() - stepStart);
228
- const icon = event.result.status === "success"
229
- ? c.ok("\u2713")
230
- : event.result.status === "skipped"
231
- ? c.subtle("\u2212")
232
- : c.fail("\u2717");
233
- const reason = event.result.status !== "success" ? event.result.data?.error : undefined;
234
- const counter = `[${stepIndex}/${totalNodes}]`;
235
- console.log(formatStepLine(icon, counter, event.node, elapsed, reason));
236
- // Inline data details
237
- const details = getStepDetails(event.node, event.result.data);
238
- for (const detail of details) {
239
- console.log(` ${c.subtle("\u21B3")} ${c.subtle(detail)}`);
240
- }
258
+ const elapsed = formatElapsed(Date.now() - stepStart);
259
+ const icon = event.result.status === "success"
260
+ ? c.ok("\u2713")
261
+ : event.result.status === "skipped"
262
+ ? c.subtle("\u2212")
263
+ : c.fail("\u2717");
264
+ const reason = event.result.status !== "success" ? event.result.data?.error : undefined;
265
+ const counter = `[${stepIndex}/${totalNodes}]`;
266
+ console.log(formatStepLine(icon, counter, event.node, elapsed, reason));
267
+ const details = getStepDetails(event.node, event.result.data);
268
+ for (const detail of details) {
269
+ console.log(` ${c.subtle("\u21B3")} ${c.subtle(detail)}`);
241
270
  }
242
271
  break;
243
272
  }
244
273
  case "route":
245
- // Optionally log routing decisions
246
274
  break;
247
275
  case "workflow:end":
248
- // Output is handled after execute() returns
249
276
  break;
250
277
  }
251
278
  };
279
+ const observer = composeObservers(progressObserver, config.stream ? createStreamObserver() : undefined);
252
280
  // ── Build workflow input from config ──────────────────────
253
281
  // TODO: The triage workflow input structure may need further refinement
254
282
  // once the workflow nodes have stabilized. For now, pass config fields
@@ -328,7 +356,7 @@ implementCmd.action(async (issueId, options) => {
328
356
  });
329
357
  console.log(chalk.cyan(`\n sweny implement ${issueId}\n`));
330
358
  const isTTY = process.stderr.isTTY ?? false;
331
- const observer = (event) => {
359
+ const implProgressObserver = (event) => {
332
360
  switch (event.type) {
333
361
  case "workflow:start":
334
362
  process.stderr.write(`\n \u25B2 ${chalk.bold(event.workflow)}\n\n`);
@@ -355,6 +383,7 @@ implementCmd.action(async (issueId, options) => {
355
383
  break;
356
384
  }
357
385
  };
386
+ const observer = composeObservers(implProgressObserver, Boolean(options.stream) ? createStreamObserver() : undefined);
358
387
  // Build workflow input for implement
359
388
  const workflowInput = {
360
389
  issueIdentifier: issueId,
@@ -446,7 +475,7 @@ export async function workflowRunAction(file, options) {
446
475
  });
447
476
  // Track per-node entry time to compute elapsed on exit
448
477
  const nodeEnterTimes = new Map();
449
- const observer = isJson
478
+ const wfProgressObserver = isJson
450
479
  ? undefined
451
480
  : (event) => {
452
481
  switch (event.type) {
@@ -467,7 +496,6 @@ export async function workflowRunAction(file, options) {
467
496
  const elapsedMs = Date.now() - enterTime;
468
497
  const elapsed = chalk.dim(elapsedMs < 1000 ? `${elapsedMs}ms` : `${Math.round(elapsedMs / 100) / 10}s`);
469
498
  if (isTTY) {
470
- // Overwrite the pending "○ nodeId…" line with the final status
471
499
  process.stderr.write(`\x1B[1A\x1B[2K ${icon} ${event.node} ${elapsed}\n`);
472
500
  }
473
501
  else {
@@ -480,6 +508,7 @@ export async function workflowRunAction(file, options) {
480
508
  break;
481
509
  }
482
510
  };
511
+ const observer = composeObservers(wfProgressObserver, options.stream ? createStreamObserver() : undefined);
483
512
  // Build workflow input from config
484
513
  const workflowInput = {
485
514
  timeRange: config.timeRange,
@@ -587,6 +616,7 @@ workflowCmd
587
616
  .description("Run a workflow from a YAML or JSON file")
588
617
  .option("--dry-run", "Validate workflow without running")
589
618
  .option("--json", "Output result as JSON on stdout; suppress progress output")
619
+ .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)")
590
620
  .action(workflowRunAction);
591
621
  workflowCmd
592
622
  .command("export <name>")
package/dist/executor.js CHANGED
@@ -52,6 +52,9 @@ export async function execute(workflow, input, options) {
52
52
  context,
53
53
  tools: trackedTools,
54
54
  outputSchema: node.output,
55
+ onProgress: (message) => {
56
+ safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
57
+ },
55
58
  });
56
59
  results.set(currentId, result);
57
60
  safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
package/dist/types.d.ts CHANGED
@@ -91,6 +91,10 @@ export type ExecutionEvent = {
91
91
  type: "node:exit";
92
92
  node: string;
93
93
  result: NodeResult;
94
+ } | {
95
+ type: "node:progress";
96
+ node: string;
97
+ message: string;
94
98
  } | {
95
99
  type: "route";
96
100
  from: string;
@@ -108,6 +112,8 @@ export interface Claude {
108
112
  context: Record<string, unknown>;
109
113
  tools: Tool[];
110
114
  outputSchema?: JSONSchema;
115
+ /** Called with status messages while Claude is working (tool name, etc.) */
116
+ onProgress?: (message: string) => void;
111
117
  }): Promise<NodeResult>;
112
118
  /** Evaluate a routing condition — pick one of N choices */
113
119
  evaluate(opts: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"