agentxchain 2.112.0 → 2.113.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.
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { findProjectRoot } from '../lib/config.js';
2
+ import { findProjectRoot, loadProjectContext } from '../lib/config.js';
3
3
  import {
4
4
  attachChainToMission,
5
5
  buildMissionListSummary,
@@ -10,6 +10,20 @@ import {
10
10
  loadMissionArtifact,
11
11
  loadMissionSnapshot,
12
12
  } from '../lib/missions.js';
13
+ import {
14
+ approvePlanArtifact,
15
+ createPlanArtifact,
16
+ launchWorkstream,
17
+ markWorkstreamOutcome,
18
+ loadAllPlans,
19
+ loadLatestPlan,
20
+ loadPlan,
21
+ buildPlannerPrompt,
22
+ parsePlannerResponse,
23
+ validatePlannerOutput,
24
+ } from '../lib/mission-plans.js';
25
+ import { executeChainedRun } from '../lib/run-chain.js';
26
+ import { executeGovernedRun } from './run.js';
13
27
 
14
28
  export async function missionStartCommand(opts) {
15
29
  const root = findProjectRoot(opts.dir || process.cwd());
@@ -163,6 +177,520 @@ export async function missionAttachChainCommand(chainId, opts) {
163
177
  renderMissionSnapshot(snapshot);
164
178
  }
165
179
 
180
+ // ── Mission Plan Commands ────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * agentxchain mission plan [mission_id|latest] — generate a decomposition plan.
184
+ *
185
+ * Uses LLM-assisted one-shot generation with schema validation.
186
+ * Falls back to deterministic stub when no LLM is available (for testing).
187
+ */
188
+ export async function missionPlanCommand(missionTarget, opts) {
189
+ const root = findProjectRoot(opts.dir || process.cwd());
190
+ if (!root) {
191
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
192
+ process.exit(1);
193
+ }
194
+
195
+ // Resolve mission target
196
+ const mission = missionTarget && missionTarget !== 'latest'
197
+ ? loadMissionArtifact(root, missionTarget)
198
+ : loadLatestMissionArtifact(root);
199
+
200
+ if (!mission) {
201
+ if (missionTarget && missionTarget !== 'latest') {
202
+ console.error(chalk.red(`Mission not found: ${missionTarget}`));
203
+ } else {
204
+ console.error(chalk.red('No missions found.'));
205
+ console.error(chalk.dim(' Run `agentxchain mission start --title "..." --goal "..."` first.'));
206
+ }
207
+ process.exit(1);
208
+ }
209
+
210
+ if (!mission.goal || !mission.goal.trim()) {
211
+ console.error(chalk.red(`Mission "${mission.mission_id}" has no goal text. The planner cannot operate on missing mission intent.`));
212
+ process.exit(1);
213
+ }
214
+
215
+ const constraints = Array.isArray(opts.constraint) ? opts.constraint : (opts.constraint ? [opts.constraint] : []);
216
+ const roleHints = Array.isArray(opts.roleHint) ? opts.roleHint : (opts.roleHint ? [opts.roleHint] : []);
217
+
218
+ // Build prompt and attempt LLM call, or use deterministic fallback
219
+ let plannerOutput;
220
+ const { systemPrompt, userPrompt } = buildPlannerPrompt(mission, constraints, roleHints);
221
+
222
+ if (opts._plannerOutput) {
223
+ // Injected planner output for testing — skip LLM call
224
+ plannerOutput = opts._plannerOutput;
225
+ } else {
226
+ // Attempt to use the configured api_proxy adapter for decomposition
227
+ try {
228
+ const { loadConfig } = await import('../lib/config.js');
229
+ const config = loadConfig(root);
230
+ const plannerConfig = config?.mission_planner || config?.api_proxy;
231
+
232
+ if (plannerConfig && plannerConfig.base_url && plannerConfig.model) {
233
+ const response = await callPlannerLLM(plannerConfig, systemPrompt, userPrompt);
234
+ const parsed = parsePlannerResponse(response);
235
+ if (!parsed.ok) {
236
+ console.error(chalk.red(`Planner response parse error: ${parsed.error}`));
237
+ process.exit(1);
238
+ }
239
+ plannerOutput = parsed.data;
240
+ } else {
241
+ console.error(chalk.red('No mission planner or api_proxy configured.'));
242
+ console.error(chalk.dim(' Add "mission_planner" or "api_proxy" to agentxchain.json with base_url and model.'));
243
+ console.error(chalk.dim(' Or pass planner output via --planner-output-file <path> for offline use.'));
244
+ process.exit(1);
245
+ }
246
+ } catch (err) {
247
+ console.error(chalk.red(`Planner call failed: ${err.message}`));
248
+ process.exit(1);
249
+ }
250
+ }
251
+
252
+ // Validate and create plan artifact
253
+ const result = createPlanArtifact(root, mission, {
254
+ constraints,
255
+ roleHints,
256
+ plannerOutput,
257
+ });
258
+
259
+ if (!result.ok) {
260
+ console.error(chalk.red('Plan validation failed:'));
261
+ for (const err of result.errors) {
262
+ console.error(chalk.red(` • ${err}`));
263
+ }
264
+ process.exit(1);
265
+ }
266
+
267
+ if (opts.json) {
268
+ console.log(JSON.stringify(result.plan, null, 2));
269
+ return;
270
+ }
271
+
272
+ console.log(chalk.green(`Created plan ${result.plan.plan_id} for mission ${mission.mission_id}`));
273
+ renderPlan(result.plan);
274
+ }
275
+
276
+ /**
277
+ * agentxchain mission plan show [plan_id|latest] — show a decomposition plan.
278
+ */
279
+ export async function missionPlanShowCommand(planTarget, opts) {
280
+ const root = findProjectRoot(opts.dir || process.cwd());
281
+ if (!root) {
282
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
283
+ process.exit(1);
284
+ }
285
+
286
+ // Resolve mission context
287
+ const mission = opts.mission
288
+ ? loadMissionArtifact(root, opts.mission)
289
+ : loadLatestMissionArtifact(root);
290
+
291
+ if (!mission) {
292
+ console.error(chalk.red('No mission found.'));
293
+ console.error(chalk.dim(' Use --mission <id> or create a mission first.'));
294
+ process.exit(1);
295
+ }
296
+
297
+ // Resolve plan target
298
+ const plan = planTarget && planTarget !== 'latest'
299
+ ? loadPlan(root, mission.mission_id, planTarget)
300
+ : loadLatestPlan(root, mission.mission_id);
301
+
302
+ if (!plan) {
303
+ if (planTarget && planTarget !== 'latest') {
304
+ console.error(chalk.red(`Plan not found: ${planTarget}`));
305
+ } else {
306
+ console.log(chalk.dim('No plans found for this mission.'));
307
+ console.log(chalk.dim(' Run `agentxchain mission plan latest` to generate one.'));
308
+ }
309
+ return;
310
+ }
311
+
312
+ if (opts.json) {
313
+ console.log(JSON.stringify(plan, null, 2));
314
+ return;
315
+ }
316
+
317
+ renderPlan(plan);
318
+ }
319
+
320
+ /**
321
+ * agentxchain mission plan list — list all plans for a mission.
322
+ */
323
+ export async function missionPlanListCommand(opts) {
324
+ const root = findProjectRoot(opts.dir || process.cwd());
325
+ if (!root) {
326
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
327
+ process.exit(1);
328
+ }
329
+
330
+ const mission = opts.mission
331
+ ? loadMissionArtifact(root, opts.mission)
332
+ : loadLatestMissionArtifact(root);
333
+
334
+ if (!mission) {
335
+ console.error(chalk.red('No mission found.'));
336
+ process.exit(1);
337
+ }
338
+
339
+ const plans = loadAllPlans(root, mission.mission_id);
340
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
341
+ const limited = plans.slice(0, limit);
342
+
343
+ if (opts.json) {
344
+ console.log(JSON.stringify(limited, null, 2));
345
+ return;
346
+ }
347
+
348
+ if (limited.length === 0) {
349
+ console.log(chalk.dim('No plans found for this mission.'));
350
+ console.log(chalk.dim(' Run `agentxchain mission plan latest` to generate one.'));
351
+ return;
352
+ }
353
+
354
+ const header = [
355
+ pad('#', 4),
356
+ pad('Plan ID', 36),
357
+ pad('Status', 12),
358
+ pad('Workstreams', 12),
359
+ pad('Supersedes', 36),
360
+ pad('Created', 22),
361
+ ].join(' ');
362
+
363
+ console.log(chalk.bold(header));
364
+ console.log(chalk.dim('─'.repeat(header.length)));
365
+
366
+ limited.forEach((plan, i) => {
367
+ console.log([
368
+ pad(String(i + 1), 4),
369
+ pad(plan.plan_id || '—', 36),
370
+ pad(formatPlanStatus(plan.status), 12),
371
+ pad(String(plan.workstreams?.length || 0), 12),
372
+ pad(plan.supersedes_plan_id || '—', 36),
373
+ pad(formatTimestamp(plan.created_at), 22),
374
+ ].join(' '));
375
+ });
376
+
377
+ console.log(chalk.dim(`\n${limited.length} plan(s) shown`));
378
+ }
379
+
380
+ /**
381
+ * agentxchain mission plan approve [plan_id|latest] — approve a decomposition plan.
382
+ */
383
+ export async function missionPlanApproveCommand(planTarget, opts) {
384
+ const root = findProjectRoot(opts.dir || process.cwd());
385
+ if (!root) {
386
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
387
+ process.exit(1);
388
+ }
389
+
390
+ const mission = opts.mission
391
+ ? loadMissionArtifact(root, opts.mission)
392
+ : loadLatestMissionArtifact(root);
393
+
394
+ if (!mission) {
395
+ console.error(chalk.red('No mission found.'));
396
+ console.error(chalk.dim(' Use --mission <id> or create a mission first.'));
397
+ process.exit(1);
398
+ }
399
+
400
+ const plan = planTarget && planTarget !== 'latest'
401
+ ? loadPlan(root, mission.mission_id, planTarget)
402
+ : loadLatestPlan(root, mission.mission_id);
403
+
404
+ if (!plan) {
405
+ if (planTarget && planTarget !== 'latest') {
406
+ console.error(chalk.red(`Plan not found: ${planTarget}`));
407
+ } else {
408
+ console.error(chalk.red(`No plans found for mission ${mission.mission_id}.`));
409
+ console.error(chalk.dim(' Run `agentxchain mission plan latest` to generate one.'));
410
+ }
411
+ process.exit(1);
412
+ }
413
+
414
+ const result = approvePlanArtifact(root, mission.mission_id, plan.plan_id);
415
+ if (!result.ok) {
416
+ console.error(chalk.red(result.error));
417
+ process.exit(1);
418
+ }
419
+
420
+ console.log(chalk.green(`Approved plan ${result.plan.plan_id} for mission ${mission.mission_id}`));
421
+ if (result.supersededPlanIds.length > 0) {
422
+ console.log(chalk.dim(` Superseded: ${result.supersededPlanIds.join(', ')}`));
423
+ }
424
+ renderPlan(result.plan);
425
+ }
426
+
427
+ /**
428
+ * agentxchain mission plan launch [plan_id|latest] --workstream <id> — launch a workstream.
429
+ *
430
+ * Validates plan approval, workstream existence, dependency satisfaction.
431
+ * Records launch_record with workstream_id → chain_id binding.
432
+ */
433
+ export async function missionPlanLaunchCommand(planTarget, opts) {
434
+ const context = loadProjectContext(opts.dir || process.cwd());
435
+ if (!context) {
436
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
437
+ process.exit(1);
438
+ }
439
+ const { root } = context;
440
+
441
+ if (!opts.workstream) {
442
+ console.error(chalk.red('--workstream <id> is required. Specify which workstream to launch.'));
443
+ process.exit(1);
444
+ }
445
+
446
+ const mission = opts.mission
447
+ ? loadMissionArtifact(root, opts.mission)
448
+ : loadLatestMissionArtifact(root);
449
+
450
+ if (!mission) {
451
+ console.error(chalk.red('No mission found.'));
452
+ console.error(chalk.dim(' Use --mission <id> or create a mission first.'));
453
+ process.exit(1);
454
+ }
455
+
456
+ const plan = planTarget && planTarget !== 'latest'
457
+ ? loadPlan(root, mission.mission_id, planTarget)
458
+ : loadLatestPlan(root, mission.mission_id);
459
+
460
+ if (!plan) {
461
+ if (planTarget && planTarget !== 'latest') {
462
+ console.error(chalk.red(`Plan not found: ${planTarget}`));
463
+ } else {
464
+ console.error(chalk.red(`No plans found for mission ${mission.mission_id}.`));
465
+ console.error(chalk.dim(' Run `agentxchain mission plan latest` to generate one.'));
466
+ }
467
+ process.exit(1);
468
+ }
469
+
470
+ const launch = launchWorkstream(root, mission.mission_id, plan.plan_id, opts.workstream);
471
+ if (!launch.ok) {
472
+ console.error(chalk.red(launch.error));
473
+ process.exit(1);
474
+ }
475
+
476
+ const executor = opts._executeGovernedRun || executeGovernedRun;
477
+ const logger = opts._log || console.log;
478
+ const chainOpts = {
479
+ enabled: true,
480
+ maxChains: 0,
481
+ chainOn: ['completed'],
482
+ cooldownSeconds: 0,
483
+ mission: mission.mission_id,
484
+ chainId: launch.chainId,
485
+ };
486
+ const runOpts = {
487
+ autoApprove: !!opts.autoApprove,
488
+ provenance: {
489
+ trigger: 'manual',
490
+ created_by: 'operator',
491
+ trigger_reason: `mission:${mission.mission_id} workstream:${opts.workstream}`,
492
+ },
493
+ };
494
+
495
+ let execution;
496
+ try {
497
+ execution = await executeChainedRun(context, runOpts, chainOpts, executor, logger);
498
+ } catch (error) {
499
+ markWorkstreamOutcome(root, mission.mission_id, plan.plan_id, opts.workstream, {
500
+ terminalReason: 'execution_error',
501
+ completedAt: new Date().toISOString(),
502
+ });
503
+ console.error(chalk.red(`Workstream execution failed: ${error.message}`));
504
+ process.exit(1);
505
+ }
506
+
507
+ const lastRun = execution?.chainReport?.runs?.[execution.chainReport.runs.length - 1] || null;
508
+ const terminalReason = lastRun?.status === 'completed'
509
+ ? 'completed'
510
+ : (lastRun?.status || execution?.chainReport?.terminal_reason || 'execution_error');
511
+ const outcome = markWorkstreamOutcome(root, mission.mission_id, plan.plan_id, opts.workstream, {
512
+ terminalReason,
513
+ completedAt: execution?.chainReport?.completed_at || new Date().toISOString(),
514
+ });
515
+ if (!outcome.ok) {
516
+ console.error(chalk.red(outcome.error));
517
+ process.exit(1);
518
+ }
519
+
520
+ if (opts.json) {
521
+ console.log(JSON.stringify({
522
+ workstream_id: opts.workstream,
523
+ chain_id: launch.chainId,
524
+ plan_id: launch.plan.plan_id,
525
+ mission_id: mission.mission_id,
526
+ launch_record: launch.launchRecord,
527
+ exit_code: execution.exitCode,
528
+ chain_terminal_reason: execution?.chainReport?.terminal_reason || null,
529
+ workstream_status: outcome.workstream.launch_status,
530
+ }, null, 2));
531
+ if (execution.exitCode !== 0) {
532
+ process.exit(execution.exitCode);
533
+ }
534
+ return;
535
+ }
536
+
537
+ console.log(chalk.green(`Executed workstream ${chalk.bold(opts.workstream)} → chain ${chalk.bold(launch.chainId)}`));
538
+ console.log('');
539
+ console.log(chalk.dim(` Mission: ${mission.mission_id}`));
540
+ console.log(chalk.dim(` Plan: ${launch.plan.plan_id}`));
541
+ console.log(chalk.dim(` Chain ID: ${launch.chainId}`));
542
+ console.log(chalk.dim(` Outcome: ${outcome.workstream.launch_status}`));
543
+ console.log('');
544
+ renderPlan(outcome.plan);
545
+ if (execution.exitCode !== 0) {
546
+ console.error(chalk.red(`Workstream execution ended with exit code ${execution.exitCode}.`));
547
+ process.exit(execution.exitCode);
548
+ }
549
+ }
550
+
551
+ // ── Plan rendering ───────────────────────────────────────────────────────────
552
+
553
+ function renderPlan(plan) {
554
+ console.log(chalk.bold(`Plan: ${plan.plan_id}`));
555
+ console.log('');
556
+ console.log(` Mission: ${plan.mission_id}`);
557
+ console.log(` Status: ${formatPlanStatus(plan.status)}`);
558
+ console.log(` Goal: ${plan.input?.goal || '—'}`);
559
+ console.log(` Constraints: ${plan.input?.constraints?.length ? plan.input.constraints.join('; ') : 'none'}`);
560
+ console.log(` Role hints: ${plan.input?.role_hints?.length ? plan.input.role_hints.join(', ') : 'none'}`);
561
+ console.log(` Supersedes: ${plan.supersedes_plan_id || '—'}`);
562
+ if (plan.superseded_by_plan_id) {
563
+ console.log(` Superseded by:${plan.superseded_by_plan_id}`);
564
+ }
565
+ if (plan.approved_at) {
566
+ console.log(` Approved: ${plan.approved_at}`);
567
+ }
568
+ console.log(` Created: ${plan.created_at || '—'}`);
569
+ console.log('');
570
+
571
+ if (!plan.workstreams || plan.workstreams.length === 0) {
572
+ console.log(chalk.dim(' No workstreams.'));
573
+ return;
574
+ }
575
+
576
+ const wsHeader = [
577
+ pad('#', 4),
578
+ pad('Workstream', 28),
579
+ pad('Status', 10),
580
+ pad('Roles', 20),
581
+ pad('Phases', 24),
582
+ pad('Depends On', 28),
583
+ 'Title',
584
+ ].join(' ');
585
+
586
+ console.log(chalk.bold(' Workstreams:'));
587
+ console.log(` ${chalk.dim(wsHeader)}`);
588
+ console.log(` ${chalk.dim('─'.repeat(wsHeader.length))}`);
589
+
590
+ plan.workstreams.forEach((ws, i) => {
591
+ const deps = Array.isArray(ws.depends_on) && ws.depends_on.length > 0
592
+ ? ws.depends_on.join(', ')
593
+ : '—';
594
+
595
+ console.log(` ${[
596
+ pad(String(i + 1), 4),
597
+ pad(ws.workstream_id || '—', 28),
598
+ pad(formatLaunchStatus(ws.launch_status), 10),
599
+ pad((ws.roles || []).join(', '), 20),
600
+ pad((ws.phases || []).join(', '), 24),
601
+ pad(deps, 28),
602
+ ws.title || '—',
603
+ ].join(' ')}`);
604
+ });
605
+
606
+ if (plan.launch_records?.length) {
607
+ console.log('');
608
+ console.log(chalk.bold(' Launch records:'));
609
+ for (const rec of plan.launch_records) {
610
+ const statusTag = rec.status === 'completed' ? chalk.green('completed')
611
+ : rec.status === 'failed' ? chalk.red('failed')
612
+ : chalk.cyan('launched');
613
+ console.log(` ${chalk.cyan(rec.workstream_id)} → ${rec.chain_id} [${statusTag}]`);
614
+ }
615
+ }
616
+
617
+ if (plan.workstreams.some((ws) => ws.acceptance_checks?.length)) {
618
+ console.log('');
619
+ console.log(chalk.bold(' Acceptance checks:'));
620
+ plan.workstreams.forEach((ws) => {
621
+ if (ws.acceptance_checks?.length) {
622
+ console.log(` ${chalk.cyan(ws.workstream_id)}:`);
623
+ ws.acceptance_checks.forEach((check) => {
624
+ console.log(` • ${check}`);
625
+ });
626
+ }
627
+ });
628
+ }
629
+ }
630
+
631
+ function formatPlanStatus(status) {
632
+ if (!status) return '—';
633
+ switch (status) {
634
+ case 'proposed': return chalk.blue('proposed');
635
+ case 'approved': return chalk.green('approved');
636
+ case 'superseded': return chalk.dim('superseded');
637
+ case 'needs_attention': return chalk.yellow('needs_attention');
638
+ case 'completed': return chalk.green('completed');
639
+ default: return status;
640
+ }
641
+ }
642
+
643
+ function formatLaunchStatus(status) {
644
+ if (!status) return '—';
645
+ switch (status) {
646
+ case 'ready': return chalk.green('ready');
647
+ case 'blocked': return chalk.yellow('blocked');
648
+ case 'launched': return chalk.cyan('launched');
649
+ case 'completed': return chalk.green('completed');
650
+ case 'needs_attention': return chalk.red('attention');
651
+ default: return status;
652
+ }
653
+ }
654
+
655
+ // ── LLM planner call ─────────────────────────────────────────────────────────
656
+
657
+ async function callPlannerLLM(config, systemPrompt, userPrompt) {
658
+ const url = `${config.base_url.replace(/\/$/, '')}/chat/completions`;
659
+ const headers = { 'Content-Type': 'application/json' };
660
+ if (config.api_key) {
661
+ headers['Authorization'] = `Bearer ${config.api_key}`;
662
+ }
663
+
664
+ const body = {
665
+ model: config.model,
666
+ messages: [
667
+ { role: 'system', content: systemPrompt },
668
+ { role: 'user', content: userPrompt },
669
+ ],
670
+ temperature: 0.3,
671
+ max_tokens: 4096,
672
+ };
673
+
674
+ const response = await fetch(url, {
675
+ method: 'POST',
676
+ headers,
677
+ body: JSON.stringify(body),
678
+ });
679
+
680
+ if (!response.ok) {
681
+ const text = await response.text().catch(() => '');
682
+ throw new Error(`LLM API returned ${response.status}: ${text.slice(0, 200)}`);
683
+ }
684
+
685
+ const json = await response.json();
686
+ const content = json?.choices?.[0]?.message?.content;
687
+ if (!content) {
688
+ throw new Error('LLM API returned no content in response.');
689
+ }
690
+
691
+ return content;
692
+ }
693
+
166
694
  function renderMissionSnapshot(snapshot) {
167
695
  console.log(chalk.bold(`Mission: ${snapshot.mission_id}`));
168
696
  console.log('');
@@ -31,6 +31,7 @@ import { evaluateApprovalSlaReminders } from '../notification-runner.js';
31
31
  import { readGateActionSnapshot } from './gate-action-reader.js';
32
32
  import { readChainReportSnapshot } from './chain-report-reader.js';
33
33
  import { readMissionSnapshot } from './mission-reader.js';
34
+ import { readPlanSnapshot } from './plan-reader.js';
34
35
 
35
36
  const MIME_TYPES = {
36
37
  '.html': 'text/html; charset=utf-8',
@@ -478,6 +479,14 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
478
479
  return;
479
480
  }
480
481
 
482
+ if (pathname === '/api/plans') {
483
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
484
+ const missionId = url.searchParams.get('mission') || undefined;
485
+ const result = readPlanSnapshot(workspacePath, { limit, missionId });
486
+ writeJson(res, result.status, result.body);
487
+ return;
488
+ }
489
+
481
490
  if (pathname === '/api/gate-actions') {
482
491
  const result = readGateActionSnapshot(workspacePath);
483
492
  writeJson(res, result.status, result.body);
@@ -8,7 +8,7 @@
8
8
  import { watch, existsSync } from 'fs';
9
9
  import { basename, join } from 'path';
10
10
  import { EventEmitter } from 'events';
11
- import { WATCH_DIRECTORIES, resourcesForRelativePath } from './state-reader.js';
11
+ import { WATCH_DIRECTORIES, RECURSIVE_WATCH_DIRECTORIES, resourcesForRelativePath } from './state-reader.js';
12
12
 
13
13
  const DEBOUNCE_MS = 100;
14
14
 
@@ -23,7 +23,7 @@ export class FileWatcher extends EventEmitter {
23
23
  this.#agentxchainDir = agentxchainDir;
24
24
  }
25
25
 
26
- #watchPath(relativeDir) {
26
+ #watchPath(relativeDir, { recursive = false } = {}) {
27
27
  if (this.#watchers.has(relativeDir)) {
28
28
  return;
29
29
  }
@@ -36,14 +36,15 @@ export class FileWatcher extends EventEmitter {
36
36
  }
37
37
 
38
38
  try {
39
- const watcher = watch(watchPath, { recursive: false }, (eventType, filename) => {
39
+ const watcher = watch(watchPath, { recursive }, (eventType, filename) => {
40
40
  if (!filename || this.#closed) return;
41
- const base = basename(filename);
42
- const relativePath = relativeDir ? `${relativeDir}/${base}` : base;
41
+ // For recursive watchers, filename includes subdirectory path
42
+ const fileSegment = recursive ? filename.replace(/\\/g, '/') : basename(filename);
43
+ const relativePath = relativeDir ? `${relativeDir}/${fileSegment}` : fileSegment;
43
44
  const resources = resourcesForRelativePath(relativePath);
44
45
 
45
46
  if (resources.length === 0) {
46
- if (!relativeDir && base === 'multirepo') {
47
+ if (!relativeDir && fileSegment === 'multirepo') {
47
48
  this.#watchPath('multirepo');
48
49
  }
49
50
  return;
@@ -79,6 +80,9 @@ export class FileWatcher extends EventEmitter {
79
80
  for (const relativeDir of WATCH_DIRECTORIES) {
80
81
  this.#watchPath(relativeDir);
81
82
  }
83
+ for (const relativeDir of RECURSIVE_WATCH_DIRECTORIES) {
84
+ this.#watchPath(relativeDir, { recursive: true });
85
+ }
82
86
  }
83
87
 
84
88
  stop() {