@agentic-surfaces/cli 0.1.28 → 0.1.30

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 (2) hide show
  1. package/dist/index.js +246 -52
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { readFileSync, readdirSync, existsSync } from "node:fs";
2
- import { join, dirname, resolve } from "node:path";
1
+ import { readFileSync, readdirSync, existsSync, watch as watchFs, } from "node:fs";
2
+ import { join, dirname, resolve, relative, sep } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { loadWorkflow, runWorkflowOnce, Scheduler, loadProjectConfig, buildWorkflowRunner, defaultRegistry, parseAgentFile, FilePauseState } from "@agentic-surfaces/core";
4
+ import { loadWorkflow, runWorkflowOnce, Scheduler, loadProjectConfig, buildWorkflowRunner, defaultRegistry, parseAgentFile, FilePauseState, } from "@agentic-surfaces/core";
5
5
  import { serve, StreamingObserver } from "@agentic-surfaces/server";
6
6
  import { buildServices } from "./services.js";
7
7
  const CONFIG = "agentic-surfaces.config.yaml";
@@ -19,7 +19,8 @@ function loadDotenv(dir = process.cwd()) {
19
19
  if (!m)
20
20
  continue; // skips blanks + `# comments`
21
21
  let val = m[2];
22
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
22
+ if ((val.startsWith('"') && val.endsWith('"')) ||
23
+ (val.startsWith("'") && val.endsWith("'")))
23
24
  val = val.slice(1, -1);
24
25
  if (process.env[m[1]] === undefined)
25
26
  process.env[m[1]] = val;
@@ -41,15 +42,63 @@ function discoverProjectDir(start) {
41
42
  dir = parent;
42
43
  }
43
44
  }
44
- // Load every workflow yaml in a folder into a name→Workflow map.
45
+ // Recursively collect every workflow yaml under `dir` as absolute file paths.
46
+ // Subdirectories are walked so workflows can be organised into folders (the
47
+ // folder path becomes the sidebar group when a workflow omits an explicit
48
+ // `group:` field — see loadWorkflows).
49
+ function collectWorkflowFiles(dir) {
50
+ const out = [];
51
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
52
+ const full = join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ out.push(...collectWorkflowFiles(full));
55
+ }
56
+ else if (entry.isFile() &&
57
+ entry.name.endsWith(".yaml") &&
58
+ entry.name !== CONFIG) {
59
+ out.push(full);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ // Derive the fallback group for a workflow file from its subdirectory path
65
+ // relative to the workflows dir. `workflows/sentry/poll.yaml` → "sentry";
66
+ // `workflows/sentry/bugs/x.yaml` → "sentry/bugs"; a file at the root → undefined.
67
+ function groupFromPath(rootDir, file) {
68
+ const rel = relative(rootDir, dirname(file));
69
+ if (!rel || rel === "." || rel.startsWith(".."))
70
+ return undefined;
71
+ return rel.split(sep).join("/");
72
+ }
73
+ // Load every workflow yaml under a folder (recursively) into a name→Workflow map.
74
+ // Grouping precedence: an explicit top-level `group:` field on the workflow wins;
75
+ // otherwise the file's subdirectory path becomes the group; a workflow at the root
76
+ // with no `group:` stays ungrouped. The map is keyed by workflow `name`, which must
77
+ // stay globally unique across subdirs (sub-workflow `foreach`/run-workflow refs
78
+ // resolve by name) — a collision is a hard error rather than a silent overwrite.
45
79
  function loadWorkflows(dir) {
46
80
  const map = new Map();
47
- for (const f of readdirSync(dir).filter((f) => f.endsWith(".yaml") && f !== CONFIG)) {
81
+ const seenAt = new Map(); // name file that defined it
82
+ for (const file of collectWorkflowFiles(dir)) {
83
+ let w;
48
84
  try {
49
- const w = loadWorkflow(readFileSync(join(dir, f), "utf8"));
50
- map.set(w.name, w);
85
+ w = loadWorkflow(readFileSync(file, "utf8"));
86
+ }
87
+ catch {
88
+ continue; /* skip unparseable */
51
89
  }
52
- catch { /* skip unparseable */ }
90
+ if (seenAt.has(w.name)) {
91
+ throw new Error(`duplicate workflow name "${w.name}" — defined in both ${relative(dir, seenAt.get(w.name))} and ${relative(dir, file)}. ` +
92
+ `Workflow names must be globally unique (they are how sub-workflows are referenced).`);
93
+ }
94
+ seenAt.set(w.name, file);
95
+ // Explicit `group:` field wins; otherwise fall back to the subdirectory path.
96
+ if (w.group === undefined) {
97
+ const g = groupFromPath(dir, file);
98
+ if (g)
99
+ w = { ...w, group: g };
100
+ }
101
+ map.set(w.name, w);
53
102
  }
54
103
  return map;
55
104
  }
@@ -71,6 +120,74 @@ function loadAgents(projectDir) {
71
120
  }
72
121
  return map;
73
122
  }
123
+ /**
124
+ * Build the in-process hot-reload function for `start`/`ui`. Re-scans the workflows dir (recursively,
125
+ * same loader as startup) + the agents dir, rebuilds the shared maps IN PLACE (so the workflow runner's
126
+ * closure over `workflows` and `services.agents` sees the new set), and re-registers the scheduler's cron
127
+ * timers from the fresh workflow set. Returns the new workflow list for the server to serve.
128
+ *
129
+ * Mutating the existing Map instances (rather than replacing them) is deliberate: the runner built by
130
+ * `buildWorkflowRunner` and the services object both close over those Map references, so `foreach` /
131
+ * run-workflow sub-workflow resolution and agent lookups pick up edits without rebuilding the runner.
132
+ *
133
+ * A reload that fails to parse the dir (e.g. mid-write) is reported and skipped — the previous good set
134
+ * stays live rather than blanking the dashboard.
135
+ */
136
+ function makeReloader(opts) {
137
+ return () => {
138
+ const next = loadWorkflows(opts.workflowsDir); // throws on duplicate names; caught by /api/reload
139
+ // Rebuild the shared workflow map in place.
140
+ opts.workflows.clear();
141
+ for (const [name, wf] of next)
142
+ opts.workflows.set(name, wf);
143
+ // Rebuild the shared agents map in place.
144
+ const nextAgents = loadAgents(opts.projectDir);
145
+ opts.agents.clear();
146
+ for (const [name, def] of nextAgents)
147
+ opts.agents.set(name, def);
148
+ // Re-register cron timers from the fresh set (cancels the old ones — no leaks, no double-fire).
149
+ opts.scheduler.reload([...next.values()]);
150
+ return [...next.values()];
151
+ };
152
+ }
153
+ /**
154
+ * Watch the project dir (recursively) and hot-reload on workflow/agent edits. Editors fire several
155
+ * fs events per save, so the reload is debounced (~400ms). After a successful reload it emits a
156
+ * `workflows:changed` SSE event so the dashboard re-fetches /api/workflows without a refresh.
157
+ *
158
+ * Loop guard: a reload only reads files (the scheduler + maps are rebuilt from them) and never writes
159
+ * back into the watched dir, so a reload cannot retrigger itself; the debounce additionally collapses
160
+ * any burst into a single reload.
161
+ *
162
+ * This is the in-process successor to the old `--watch` (which exited for a supervisor restart). The
163
+ * launchd path that relied on `start --watch` still works — see the `start` command for the flag's
164
+ * retained behavior.
165
+ */
166
+ function watchAndReload(dir, observer, reload) {
167
+ let pending;
168
+ try {
169
+ watchFs(dir, { recursive: true }, (_event, filename) => {
170
+ if (!filename || !/\.(yaml|md)$/.test(filename.toString()))
171
+ return;
172
+ clearTimeout(pending);
173
+ pending = setTimeout(() => {
174
+ try {
175
+ const next = reload();
176
+ observer.notifyWorkflowsChanged();
177
+ console.log(`hot-reload: ${next.length} workflow(s) (changed: ${filename})`);
178
+ }
179
+ catch (err) {
180
+ // Keep the previous good set live; just report (e.g. mid-write parse error or a name clash).
181
+ console.error("hot-reload skipped:", err instanceof Error ? err.message : String(err));
182
+ }
183
+ }, 400); // debounce editor multi-writes
184
+ });
185
+ }
186
+ catch (err) {
187
+ // recursive watch isn't supported everywhere; degrade to manual reload (the Reload button).
188
+ console.error("hot-reload watch unavailable (use the Reload button):", err instanceof Error ? err.message : String(err));
189
+ }
190
+ }
74
191
  // Walk up to the directory that contains agentic-surfaces.config.yaml.
75
192
  function findProjectDir(start) {
76
193
  let dir = resolve(start);
@@ -106,7 +223,14 @@ function launchUi(opts) {
106
223
  onRun: opts.onRun,
107
224
  cache: opts.cache,
108
225
  pause: opts.pause,
109
- config: { dryRun: opts.dryRun, agent: opts.agentDefaults, version: VERSION, atlassianSite: opts.atlassianSite },
226
+ planUsage: opts.planUsage,
227
+ onReload: opts.onReload,
228
+ config: {
229
+ dryRun: opts.dryRun,
230
+ agent: opts.agentDefaults,
231
+ version: VERSION,
232
+ atlassianSite: opts.atlassianSite,
233
+ },
110
234
  });
111
235
  const url = `http://127.0.0.1:${p}`;
112
236
  openBrowser(url);
@@ -115,8 +239,10 @@ function launchUi(opts) {
115
239
  function openBrowser(url) {
116
240
  if (process.env["FLOW_NO_OPEN"])
117
241
  return; // headless / CI / don't hijack a browser
118
- const cmd = process.platform === "darwin" ? "open"
119
- : process.platform === "win32" ? "start"
242
+ const cmd = process.platform === "darwin"
243
+ ? "open"
244
+ : process.platform === "win32"
245
+ ? "start"
120
246
  : "xdg-open";
121
247
  try {
122
248
  spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
@@ -163,8 +289,17 @@ export async function run(argv) {
163
289
  const registry = defaultRegistry();
164
290
  const observer = new StreamingObserver();
165
291
  const services = buildServices({ projectConfig: pc, projectDir: dir });
166
- const runWorkflowFn = buildWorkflowRunner({ workflows: allWorkflows, registry, services: { ...services, agents }, observer });
167
- const servicesWithRunner = { ...services, agents, runWorkflow: runWorkflowFn };
292
+ const runWorkflowFn = buildWorkflowRunner({
293
+ workflows: allWorkflows,
294
+ registry,
295
+ services: { ...services, agents },
296
+ observer,
297
+ });
298
+ const servicesWithRunner = {
299
+ ...services,
300
+ agents,
301
+ runWorkflow: runWorkflowFn,
302
+ };
168
303
  // Platform-wide play/pause (file-backed so it survives a --watch restart).
169
304
  const pause = new FilePauseState();
170
305
  // Cron-triggered workflows fire on schedule too.
@@ -172,17 +307,31 @@ export async function run(argv) {
172
307
  for (const [, w] of allWorkflows)
173
308
  sched.add(w);
174
309
  sched.start();
310
+ const reload = makeReloader({
311
+ workflowsDir,
312
+ projectDir: dir,
313
+ workflows: allWorkflows,
314
+ agents,
315
+ scheduler: sched,
316
+ });
175
317
  const url = launchUi({
176
- observer, workflows: allWorkflows, agents,
318
+ observer,
319
+ workflows: allWorkflows,
320
+ agents,
177
321
  onRun: (name, payload) => runWorkflowFn(name, payload),
178
322
  cache: services.cache,
179
323
  pause,
324
+ planUsage: () => services.agent.getPlanUsage?.() ?? Promise.resolve(null),
180
325
  dryRun: pc?.dryRun,
181
326
  agentDefaults: { model: pc?.agent?.model, effort: pc?.agent?.effort },
182
327
  atlassianSite: pc?.atlassianSite,
328
+ onReload: reload,
183
329
  });
330
+ // Automatic hot reload: a debounced recursive watch on the project dir re-scans + re-registers
331
+ // the scheduler in place and pushes a `workflows:changed` SSE event so the dashboard re-fetches.
332
+ watchAndReload(dir, observer, reload);
184
333
  console.log(`\n▶ agentic-surfaces ${VERSION} — ${url}`);
185
- console.log(` project: ${dir} · ${allWorkflows.size} workflow(s) · Ctrl-C to exit`);
334
+ console.log(` project: ${dir} · ${allWorkflows.size} workflow(s) · hot-reload on · Ctrl-C to exit`);
186
335
  await new Promise(() => { }); // serve until interrupted
187
336
  return 0;
188
337
  }
@@ -192,7 +341,7 @@ export async function run(argv) {
192
341
  return 0;
193
342
  }
194
343
  if (cmd === "run-once" || cmd === "run") {
195
- const file = rest.find(a => !a.startsWith("--"));
344
+ const file = rest.find((a) => !a.startsWith("--"));
196
345
  if (!file) {
197
346
  console.error("run: missing workflow file");
198
347
  return 1;
@@ -201,36 +350,60 @@ export async function run(argv) {
201
350
  const pc = findProjectConfig(dirname(file));
202
351
  const wf = loadWorkflow(readFileSync(file, "utf8"));
203
352
  const projectDir = findProjectDir(dirname(file)) ?? dirname(file);
204
- const services = buildServices(fake ? { fakeAgent: [{ text: "fake reply" }], projectConfig: pc, projectDir } : { projectConfig: pc, projectDir });
205
- const dir = dirname(file);
206
- const allWorkflows = new Map();
207
- for (const f of readdirSync(dir).filter(f => f.endsWith(".yaml") && f !== "agentic-surfaces.config.yaml")) {
208
- try {
209
- const w = loadWorkflow(readFileSync(join(dir, f), "utf8"));
210
- allWorkflows.set(w.name, w);
353
+ const services = buildServices(fake
354
+ ? {
355
+ fakeAgent: [{ text: "fake reply" }],
356
+ projectConfig: pc,
357
+ projectDir,
211
358
  }
212
- catch { /* skip unparseable */ }
213
- }
359
+ : { projectConfig: pc, projectDir });
360
+ // Scan recursively from the workflows root (so sub-workflow refs resolve
361
+ // across subdir folders + groups come through), falling back to the file's
362
+ // own dir when no project config is present.
363
+ const scanDir = pc?.workflows
364
+ ? join(projectDir, pc.workflows)
365
+ : dirname(file);
366
+ const allWorkflows = loadWorkflows(scanDir);
214
367
  const agents = loadAgents(projectDir);
215
368
  const registry = defaultRegistry();
216
369
  const ui = rest.includes("--ui");
217
370
  const observer = ui ? new StreamingObserver() : undefined;
218
- const runWorkflowFn = buildWorkflowRunner({ workflows: allWorkflows, registry, services: { ...services, agents }, observer });
219
- const servicesWithRunner = { ...services, agents, runWorkflow: runWorkflowFn };
220
- const url = ui ? launchUi({
221
- observer: observer, workflows: allWorkflows, agents,
222
- onRun: (name, payload) => runWorkflowFn(name, payload),
223
- cache: services.cache,
224
- dryRun: pc?.dryRun,
225
- agentDefaults: { model: pc?.agent?.model, effort: pc?.agent?.effort },
226
- atlassianSite: pc?.atlassianSite,
227
- }) : undefined;
371
+ const runWorkflowFn = buildWorkflowRunner({
372
+ workflows: allWorkflows,
373
+ registry,
374
+ services: { ...services, agents },
375
+ observer,
376
+ });
377
+ const servicesWithRunner = {
378
+ ...services,
379
+ agents,
380
+ runWorkflow: runWorkflowFn,
381
+ };
382
+ const url = ui
383
+ ? launchUi({
384
+ observer: observer,
385
+ workflows: allWorkflows,
386
+ agents,
387
+ onRun: (name, payload) => runWorkflowFn(name, payload),
388
+ cache: services.cache,
389
+ dryRun: pc?.dryRun,
390
+ agentDefaults: {
391
+ model: pc?.agent?.model,
392
+ effort: pc?.agent?.effort,
393
+ },
394
+ atlassianSite: pc?.atlassianSite,
395
+ })
396
+ : undefined;
228
397
  // A run error must NOT tear down the --ui server: the failure is already
229
398
  // streamed to the dashboard (the failed node + run), so keep serving so it
230
399
  // stays inspectable. Without --ui, an error still exits non-zero.
231
400
  let failed = false;
232
401
  try {
233
- const outputs = await runWorkflowOnce(wf, { registry, services: servicesWithRunner, observer });
402
+ const outputs = await runWorkflowOnce(wf, {
403
+ registry,
404
+ services: servicesWithRunner,
405
+ observer,
406
+ });
234
407
  console.log("ran", wf.name, "->", [...outputs.keys()].join(", "));
235
408
  }
236
409
  catch (err) {
@@ -244,47 +417,68 @@ export async function run(argv) {
244
417
  return failed ? 1 : 0;
245
418
  }
246
419
  if (cmd === "start") {
247
- const dir = rest.find(a => !a.startsWith("--")) ?? ".";
420
+ const dir = rest.find((a) => !a.startsWith("--")) ?? ".";
248
421
  const pc = loadProjectConfig(dir);
249
422
  const workflowsDir = pc?.workflows ? join(dir, pc.workflows) : dir;
250
423
  const services = buildServices({ projectConfig: pc, projectDir: dir });
251
- const allWorkflows = new Map();
252
- const yamlFiles = readdirSync(workflowsDir).filter(f => f.endsWith(".yaml") && f !== "agentic-surfaces.config.yaml");
253
- for (const f of yamlFiles) {
254
- try {
255
- const w = loadWorkflow(readFileSync(join(workflowsDir, f), "utf8"));
256
- allWorkflows.set(w.name, w);
257
- }
258
- catch { /* skip unparseable */ }
259
- }
424
+ const allWorkflows = loadWorkflows(workflowsDir);
260
425
  const agents = loadAgents(dir);
261
426
  const registry = defaultRegistry();
262
427
  const ui = rest.includes("--ui");
263
428
  const observer = ui ? new StreamingObserver() : undefined;
264
- const runWorkflowFn = buildWorkflowRunner({ workflows: allWorkflows, registry, services: { ...services, agents }, observer });
265
- const servicesWithRunner = { ...services, agents, runWorkflow: runWorkflowFn };
429
+ const runWorkflowFn = buildWorkflowRunner({
430
+ workflows: allWorkflows,
431
+ registry,
432
+ services: { ...services, agents },
433
+ observer,
434
+ });
435
+ const servicesWithRunner = {
436
+ ...services,
437
+ agents,
438
+ runWorkflow: runWorkflowFn,
439
+ };
266
440
  const pause = new FilePauseState();
267
441
  const sched = new Scheduler(servicesWithRunner, registry, observer, pause);
268
442
  for (const [, w] of allWorkflows)
269
443
  sched.add(w);
270
444
  sched.start();
445
+ const reload = makeReloader({
446
+ workflowsDir,
447
+ projectDir: dir,
448
+ workflows: allWorkflows,
449
+ agents,
450
+ scheduler: sched,
451
+ });
271
452
  console.log(`scheduler started; watching ${workflowsDir}${pause.isPaused() ? " (PAUSED)" : ""}`);
453
+ const watchFlag = rest.includes("--watch");
272
454
  if (ui) {
273
455
  const url = launchUi({
274
- observer: observer, workflows: allWorkflows, agents,
456
+ observer: observer,
457
+ workflows: allWorkflows,
458
+ agents,
275
459
  onRun: (name, payload) => runWorkflowFn(name, payload),
276
460
  cache: services.cache,
277
461
  pause,
462
+ planUsage: () => services.agent.getPlanUsage?.() ?? Promise.resolve(null),
278
463
  dryRun: pc?.dryRun,
279
464
  agentDefaults: { model: pc?.agent?.model, effort: pc?.agent?.effort },
280
465
  atlassianSite: pc?.atlassianSite,
466
+ onReload: reload,
281
467
  });
282
468
  console.log(`▶ agentic-surfaces ${VERSION} — ${url}`);
469
+ // With the dashboard up, prefer in-process hot reload (button + automatic) over the legacy
470
+ // process-exit watcher — unless --watch is explicitly passed (the supervisor-restart contract).
471
+ if (!watchFlag) {
472
+ watchAndReload(dir, observer, reload);
473
+ console.log("hot-reload on (Reload button + automatic file watch)");
474
+ }
283
475
  }
284
476
  // --watch: exit when a config file changes so a supervisor (launchd/pm2/systemd) restarts us
285
477
  // with the fresh config. The seen-cache (.flow-cache.sqlite) persists across the restart, so
286
- // dedup is preserved. Without a supervisor this just exits on the first edit.
287
- if (rest.includes("--watch")) {
478
+ // dedup is preserved. Without a supervisor this just exits on the first edit. RETAINED for the
479
+ // launchd path; the dashboard's in-process hot reload above is the lighter-weight default when
480
+ // --watch is omitted. When --watch is set it wins (a supervisor expects the process to exit).
481
+ if (watchFlag) {
288
482
  const { watch } = await import("node:fs");
289
483
  let pending;
290
484
  watch(dir, { recursive: true }, (_event, filename) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentic-surfaces/cli",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -22,9 +22,9 @@
22
22
  "README.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@agentic-surfaces/core": "0.1.28",
26
- "@agentic-surfaces/agent": "0.1.28",
27
- "@agentic-surfaces/server": "0.1.28"
25
+ "@agentic-surfaces/agent": "0.1.30",
26
+ "@agentic-surfaces/core": "0.1.30",
27
+ "@agentic-surfaces/server": "0.1.30"
28
28
  },
29
29
  "repository": {
30
30
  "type": "git",