@worca/ui 0.11.0 → 0.13.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/app/protocol.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Protocol definitions for worca-ui WebSocket communication.
3
3
  */
4
4
 
5
- /** @typedef {'subscribe-run'|'unsubscribe-run'|'subscribe-log'|'unsubscribe-log'|'list-runs'|'get-agent-prompt'|'get-preferences'|'set-preferences'|'stop-run'|'resume-run'|'list-beads-issues'|'start-beads-issue'|'list-beads-counts'|'list-beads-refs'|'list-beads-unlinked'|'run-snapshot'|'run-update'|'runs-list'|'log-line'|'log-bulk'|'preferences'|'run-started'|'run-stopped'|'stage-restarted'|'beads-update'} MessageType */
5
+ /** @typedef {'subscribe-run'|'unsubscribe-run'|'subscribe-log'|'unsubscribe-log'|'list-runs'|'get-agent-prompt'|'get-preferences'|'set-preferences'|'stop-run'|'resume-run'|'list-beads-issues'|'start-beads-issue'|'list-beads-counts'|'list-beads-refs'|'list-beads-unlinked'|'run-snapshot'|'run-update'|'runs-list'|'log-line'|'log-bulk'|'preferences'|'run-started'|'run-stopped'|'stage-restarted'|'beads-update'|'hello'|'hello-ack'} MessageType */
6
6
 
7
7
  /** @type {MessageType[]} */
8
8
  export const MESSAGE_TYPES = [
@@ -29,12 +29,6 @@ export const MESSAGE_TYPES = [
29
29
  // Protocol handshake
30
30
  'hello',
31
31
  'hello-ack',
32
- // Parallel pipelines
33
- 'list-pipelines',
34
- 'subscribe-pipeline',
35
- 'unsubscribe-pipeline',
36
- 'pipeline-status-changed',
37
- 'pipelines-list',
38
32
  // Server → Client events
39
33
  'run-snapshot',
40
34
  'run-update',
package/app/styles.css CHANGED
@@ -4525,3 +4525,61 @@ sl-tooltip.bead-tooltip::part(body) {
4525
4525
  padding: 2rem;
4526
4526
  }
4527
4527
 
4528
+
4529
+ /* --- Worktrees view --- */
4530
+ .worktrees-view {
4531
+ display: flex;
4532
+ flex-direction: column;
4533
+ gap: 12px;
4534
+ }
4535
+ .worktrees-summary {
4536
+ display: flex;
4537
+ flex-wrap: wrap;
4538
+ gap: 4px 6px;
4539
+ align-items: baseline;
4540
+ padding: 6px 0 2px;
4541
+ font-size: 12px;
4542
+ color: var(--muted);
4543
+ font-variant-numeric: tabular-nums;
4544
+ }
4545
+ .worktrees-summary .meta-sep {
4546
+ color: var(--border-subtle);
4547
+ margin: 0 2px;
4548
+ }
4549
+ .worktrees-disk-alert {
4550
+ margin-bottom: 4px;
4551
+ }
4552
+ .worktrees-toolbar {
4553
+ display: flex;
4554
+ align-items: center;
4555
+ gap: 12px;
4556
+ flex-wrap: wrap;
4557
+ }
4558
+ .worktrees-toolbar .worktrees-filter {
4559
+ flex: 1;
4560
+ min-width: 240px;
4561
+ }
4562
+ .worktree-card-path {
4563
+ align-items: baseline;
4564
+ }
4565
+ .worktree-path-mono {
4566
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
4567
+ font-size: 11px;
4568
+ color: var(--muted);
4569
+ word-break: break-all;
4570
+ }
4571
+ .worktrees-bulk-groups {
4572
+ margin: 8px 0 0;
4573
+ padding-left: 18px;
4574
+ font-size: 13px;
4575
+ color: var(--muted);
4576
+ }
4577
+ .worktrees-bulk-groups li {
4578
+ margin-bottom: 2px;
4579
+ }
4580
+ .dialog-actions {
4581
+ display: flex;
4582
+ gap: 8px;
4583
+ justify-content: flex-end;
4584
+ width: 100%;
4585
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -84,6 +84,43 @@ export class ProcessManager {
84
84
  this.settingsPath = settingsPath ?? null;
85
85
  }
86
86
 
87
+ /**
88
+ * Resolve the worcaDir and runDir for a given run ID.
89
+ * Checks root runs/ first, then pipelines.d/ registry for worktree_path.
90
+ * @param {string} runId
91
+ * @returns {{ worcaDir: string, runDir: string } | null}
92
+ */
93
+ resolveRunContext(runId) {
94
+ const rootPath = join(this.worcaDir, 'runs', runId, 'status.json');
95
+ if (existsSync(rootPath)) {
96
+ return {
97
+ worcaDir: this.worcaDir,
98
+ runDir: join(this.worcaDir, 'runs', runId),
99
+ };
100
+ }
101
+ const regPath = join(
102
+ this.worcaDir,
103
+ 'multi',
104
+ 'pipelines.d',
105
+ `${runId}.json`,
106
+ );
107
+ if (existsSync(regPath)) {
108
+ try {
109
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
110
+ if (reg.worktree_path) {
111
+ const wtWorcaDir = join(reg.worktree_path, '.worca');
112
+ return {
113
+ worcaDir: wtWorcaDir,
114
+ runDir: join(wtWorcaDir, 'runs', runId),
115
+ };
116
+ }
117
+ } catch {
118
+ /* ignore */
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
87
124
  /**
88
125
  * Check if a pipeline is currently running.
89
126
  * @param {string} [runId] - If provided, check per-run PID first
@@ -125,7 +162,7 @@ export class ProcessManager {
125
162
 
126
163
  /**
127
164
  * Reconcile stale "running" status when the pipeline process is dead.
128
- * Scans all runs with per-run PID files + the active_run pointer.
165
+ * Scans all runs with per-run PID files.
129
166
  * If pipeline_status is "running" but no process is alive, transitions
130
167
  * to "failed" with stop_reason="stale".
131
168
  * Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
@@ -136,7 +173,7 @@ export class ProcessManager {
136
173
  let fixed = false;
137
174
  const dispatches = [];
138
175
 
139
- // Collect run IDs to check: scan runs/*/pipeline.pid + active_run fallback
176
+ // Collect run IDs to check: scan runs/*/pipeline.pid
140
177
  const runIds = new Set();
141
178
  const runsDir = join(this.worcaDir, 'runs');
142
179
  if (existsSync(runsDir)) {
@@ -154,17 +191,6 @@ export class ProcessManager {
154
191
  }
155
192
  }
156
193
 
157
- // Backward compat: also check active_run pointer
158
- const activeRunPath = join(this.worcaDir, 'active_run');
159
- if (existsSync(activeRunPath)) {
160
- try {
161
- const activeId = readFileSync(activeRunPath, 'utf8').trim();
162
- if (activeId) runIds.add(activeId);
163
- } catch {
164
- /* ignore */
165
- }
166
- }
167
-
168
194
  for (const runId of runIds) {
169
195
  // Check if this run's process is alive
170
196
  const alive = this.getRunningPid(runId);
@@ -272,20 +298,47 @@ export class ProcessManager {
272
298
  */
273
299
  async startPipeline(opts = {}) {
274
300
  const cwd = opts.projectRoot || this.projectRoot;
275
- const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
276
- if (!existsSync(scriptPath)) {
277
- const err = new Error(`Pipeline script not found at ${scriptPath}`);
278
- err.code = 'script_not_found';
279
- throw err;
301
+ const pipelineScriptRel = '.claude/worca/scripts/run_pipeline.py';
302
+ const worktreeScriptRel = '.claude/worca/scripts/run_worktree.py';
303
+
304
+ let scriptRel;
305
+ if (opts.resume) {
306
+ const pipelinePath = join(cwd, pipelineScriptRel);
307
+ if (!existsSync(pipelinePath)) {
308
+ const err = new Error(`Pipeline script not found at ${pipelinePath}`);
309
+ err.code = 'script_not_found';
310
+ throw err;
311
+ }
312
+ scriptRel = pipelineScriptRel;
313
+ } else {
314
+ const worktreePath = join(cwd, worktreeScriptRel);
315
+ if (existsSync(worktreePath)) {
316
+ scriptRel = worktreeScriptRel;
317
+ } else {
318
+ const pipelinePath = join(cwd, pipelineScriptRel);
319
+ if (!existsSync(pipelinePath)) {
320
+ const err = new Error(`Pipeline script not found at ${pipelinePath}`);
321
+ err.code = 'script_not_found';
322
+ throw err;
323
+ }
324
+ console.warn(
325
+ '[worca] run_worktree.py not found, falling back to run_pipeline.py',
326
+ );
327
+ scriptRel = pipelineScriptRel;
328
+ }
280
329
  }
281
330
 
282
- const args = ['.claude/worca/scripts/run_pipeline.py'];
331
+ const args = [scriptRel];
283
332
  let promptFilePath = null; // track for cleanup on spawn failure
284
333
 
285
334
  if (opts.resume) {
286
335
  args.push('--resume');
287
336
  if (opts.runId) {
288
- args.push('--status-dir', join(this.worcaDir, 'runs', opts.runId));
337
+ const ctx = this.resolveRunContext(opts.runId);
338
+ const statusDir = ctx
339
+ ? ctx.runDir
340
+ : join(this.worcaDir, 'runs', opts.runId);
341
+ args.push('--status-dir', statusDir);
289
342
  }
290
343
  } else if (opts.sourceType !== undefined) {
291
344
  // New format: separate source and prompt args
@@ -400,10 +453,13 @@ export class ProcessManager {
400
453
  let pid = null;
401
454
  let foundPidPath = null;
402
455
 
456
+ const ctx = runId ? this.resolveRunContext(runId) : null;
457
+ const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
458
+
403
459
  // Check per-run PID file first, then project-level fallback
404
460
  const candidates = [];
405
461
  if (runId) {
406
- candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
462
+ candidates.push(join(effectiveWorcaDir, 'runs', runId, 'pipeline.pid'));
407
463
  }
408
464
  candidates.push(join(this.worcaDir, 'pipeline.pid'));
409
465
 
@@ -431,10 +487,10 @@ export class ProcessManager {
431
487
  }
432
488
 
433
489
  // Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
434
- const effectiveRunId = runId || this._readActiveRunId();
490
+ const effectiveRunId = runId;
435
491
  if (effectiveRunId) {
436
492
  try {
437
- const controlDir = join(this.worcaDir, 'runs', effectiveRunId);
493
+ const controlDir = join(effectiveWorcaDir, 'runs', effectiveRunId);
438
494
  mkdirSync(controlDir, { recursive: true });
439
495
  writeFileSync(
440
496
  join(controlDir, 'control.json'),
@@ -514,7 +570,9 @@ export class ProcessManager {
514
570
  }
515
571
  const { pid } = running;
516
572
 
517
- const controlDir = join(this.worcaDir, 'runs', runId);
573
+ const ctx = this.resolveRunContext(runId);
574
+ const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
575
+ const controlDir = join(effectiveWorcaDir, 'runs', runId);
518
576
  mkdirSync(controlDir, { recursive: true });
519
577
  writeFileSync(
520
578
  join(controlDir, 'control.json'),
@@ -569,21 +627,6 @@ export class ProcessManager {
569
627
  }
570
628
  }
571
629
 
572
- /**
573
- * Read the active_run file to get the current run ID.
574
- * @returns {string|null}
575
- */
576
- _readActiveRunId() {
577
- const activeRunPath = join(this.worcaDir, 'active_run');
578
- if (!existsSync(activeRunPath)) return null;
579
- try {
580
- const id = readFileSync(activeRunPath, 'utf8').trim();
581
- return id || null;
582
- } catch {
583
- return null;
584
- }
585
- }
586
-
587
630
  /**
588
631
  * Delete a run directory and clean up references.
589
632
  * Refuses if the pipeline is currently running.
@@ -600,8 +643,10 @@ export class ProcessManager {
600
643
  throw err;
601
644
  }
602
645
 
603
- const runsParent = resolve(this.worcaDir, 'runs');
604
- const runDir = resolve(runsParent, runId);
646
+ const ctx = this.resolveRunContext(runId);
647
+ const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
648
+ const runsParent = resolve(effectiveWorcaDir, 'runs');
649
+ const runDir = ctx ? resolve(ctx.runDir) : resolve(runsParent, runId);
605
650
  if (!runDir.startsWith(runsParent)) {
606
651
  const err = new Error('Invalid runId');
607
652
  err.code = 'invalid_id';
@@ -615,17 +660,6 @@ export class ProcessManager {
615
660
 
616
661
  rmSync(runDir, { recursive: true, force: true });
617
662
 
618
- // Clear active_run pointer if it references this run
619
- const activeRunPath = join(this.worcaDir, 'active_run');
620
- if (existsSync(activeRunPath)) {
621
- try {
622
- const activeId = readFileSync(activeRunPath, 'utf8').trim();
623
- if (activeId === runId) unlinkSync(activeRunPath);
624
- } catch {
625
- /* ignore */
626
- }
627
- }
628
-
629
663
  return { deleted: true };
630
664
  }
631
665
 
@@ -635,7 +669,8 @@ export class ProcessManager {
635
669
  * @returns {{ runId: string, paused: boolean }}
636
670
  */
637
671
  pausePipeline(runId) {
638
- const controlDir = join(this.worcaDir, 'runs', runId);
672
+ const ctx = this.resolveRunContext(runId);
673
+ const controlDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
639
674
  mkdirSync(controlDir, { recursive: true });
640
675
  writeFileSync(
641
676
  join(controlDir, 'control.json'),
@@ -655,19 +690,34 @@ export class ProcessManager {
655
690
 
656
691
  /**
657
692
  * Restart a failed stage by resetting it and spawning with --resume.
693
+ *
694
+ * Internal API — only called from worca-ui (project-routes.js). The signature
695
+ * changed in W-048 from (stageKey, opts) to (runId, stageKey, opts) because
696
+ * runs are now per-worktree and the manager can no longer infer "the active
697
+ * run". Callers must pass an explicit runId.
698
+ *
699
+ * @param {string} runId - Run identifier to restart
658
700
  * @param {string} stageKey - The stage key to restart
659
701
  * @param {{ projectRoot?: string }} opts
660
702
  * @returns {Promise<{ pid: number, stage: string }>}
661
703
  */
662
- async restartStage(stageKey, opts = {}) {
663
- const running = this.getRunningPid();
704
+ async restartStage(runId, stageKey, opts = {}) {
705
+ const running = this.getRunningPid(runId);
664
706
  if (running) {
665
707
  const err = new Error(`Pipeline already running (PID ${running.pid})`);
666
708
  err.code = 'already_running';
667
709
  throw err;
668
710
  }
669
711
 
670
- const cwd = opts.projectRoot || this.projectRoot;
712
+ const ctx = this.resolveRunContext(runId);
713
+ const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
714
+ // For worktree runs derive projectRoot from worcaDir parent (.worca/..)
715
+ const cwd =
716
+ opts.projectRoot ||
717
+ (ctx && ctx.worcaDir !== this.worcaDir
718
+ ? join(ctx.worcaDir, '..')
719
+ : this.projectRoot);
720
+
671
721
  const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
672
722
  if (!existsSync(scriptPath)) {
673
723
  const err = new Error(`Pipeline script not found at ${scriptPath}`);
@@ -675,24 +725,8 @@ export class ProcessManager {
675
725
  throw err;
676
726
  }
677
727
 
678
- // Find status.json — check active_run first, then legacy
679
- let statusPath = null;
680
- const activeRunPath = join(this.worcaDir, 'active_run');
681
- if (existsSync(activeRunPath)) {
682
- try {
683
- const runId = readFileSync(activeRunPath, 'utf8').trim();
684
- const candidate = join(this.worcaDir, 'runs', runId, 'status.json');
685
- if (existsSync(candidate)) statusPath = candidate;
686
- } catch {
687
- /* ignore */
688
- }
689
- }
690
- if (!statusPath) {
691
- const legacy = join(this.worcaDir, 'status.json');
692
- if (existsSync(legacy)) statusPath = legacy;
693
- }
694
-
695
- if (!statusPath) {
728
+ const statusPath = join(runDir, 'status.json');
729
+ if (!existsSync(statusPath)) {
696
730
  const err = new Error('No status.json found');
697
731
  err.code = 'no_status';
698
732
  throw err;
@@ -720,14 +754,19 @@ export class ProcessManager {
720
754
  delete status.stages[stageKey].completed_at;
721
755
  writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
722
756
 
723
- // Spawn with --resume
757
+ // Spawn with --resume --status-dir so the pipeline finds the right run
724
758
  const env = { ...process.env };
725
759
  delete env.CLAUDECODE;
726
760
 
727
761
  return new Promise((resolve, reject) => {
728
762
  const child = spawn(
729
763
  'python3',
730
- ['.claude/worca/scripts/run_pipeline.py', '--resume'],
764
+ [
765
+ '.claude/worca/scripts/run_pipeline.py',
766
+ '--resume',
767
+ '--status-dir',
768
+ runDir,
769
+ ],
731
770
  {
732
771
  detached: true,
733
772
  stdio: 'ignore',
@@ -807,10 +846,10 @@ export function pausePipeline(worcaDir, runId) {
807
846
  return new ProcessManager({ worcaDir }).pausePipeline(runId);
808
847
  }
809
848
 
810
- /** @param {string} worcaDir @param {string} stageKey @param {object} opts */
811
- export async function restartStage(worcaDir, stageKey, opts = {}) {
849
+ /** @param {string} worcaDir @param {string} runId @param {string} stageKey @param {object} opts */
850
+ export async function restartStage(worcaDir, runId, stageKey, opts = {}) {
812
851
  return new ProcessManager({
813
852
  worcaDir,
814
853
  projectRoot: opts.projectRoot,
815
- }).restartStage(stageKey, opts);
854
+ }).restartStage(runId, stageKey, opts);
816
855
  }
@@ -48,6 +48,7 @@ import {
48
48
  readProjectWorcaVersion,
49
49
  runWorcaSetup,
50
50
  } from './worca-setup.js';
51
+ import { createWorktreesRouter } from './worktrees-routes.js';
51
52
 
52
53
  /** Validate a runId — must not contain path traversal characters */
53
54
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
@@ -1135,7 +1136,7 @@ export function createProjectScopedRoutes({
1135
1136
  async (req, res) => {
1136
1137
  const { stage } = req.params;
1137
1138
  try {
1138
- const result = await req.project.pm.restartStage(stage);
1139
+ const result = await req.project.pm.restartStage(req.params.id, stage);
1139
1140
  const { broadcast } = req.app.locals;
1140
1141
  if (broadcast) broadcast('stage-restarted', { stage, pid: result.pid });
1141
1142
  res.json({ ok: true, restarted: true, stage, pid: result.pid });
@@ -1249,94 +1250,7 @@ export function createProjectScopedRoutes({
1249
1250
  res.json({ ok: true, runId, pid: child.pid });
1250
1251
  });
1251
1252
 
1252
- // POST /api/projects/:projectId/multi-pipelinelaunch parallel pipelines
1253
- router.post('/multi-pipeline', requireWorcaDir, (req, res) => {
1254
- const { projectRoot } = req.project;
1255
- const body = req.body || {};
1256
- const { requests, baseBranch, maxParallel, cleanupPolicy, msize, mloops } =
1257
- body;
1258
-
1259
- if (!Array.isArray(requests) || requests.length < 1) {
1260
- return res.status(400).json({
1261
- ok: false,
1262
- error: 'requests array required (at least 1 item)',
1263
- });
1264
- }
1265
- if (requests.length > 20) {
1266
- return res
1267
- .status(400)
1268
- .json({ ok: false, error: 'Too many requests (max 20)' });
1269
- }
1270
- for (const r of requests) {
1271
- if (typeof r !== 'string' || r.trim().length === 0) {
1272
- return res.status(400).json({
1273
- ok: false,
1274
- error: 'Each request must be a non-empty string',
1275
- });
1276
- }
1277
- if (r.length > 50000) {
1278
- return res.status(400).json({
1279
- ok: false,
1280
- error: 'Each request must be 50,000 characters or less',
1281
- });
1282
- }
1283
- }
1284
- if (baseBranch !== undefined) {
1285
- if (
1286
- typeof baseBranch !== 'string' ||
1287
- baseBranch.length > 200 ||
1288
- !/^[\w.\-/]+$/.test(baseBranch)
1289
- ) {
1290
- return res
1291
- .status(400)
1292
- .json({ ok: false, error: 'Invalid baseBranch value' });
1293
- }
1294
- }
1295
-
1296
- const maxP = Math.max(1, Math.min(5, Math.round(Number(maxParallel) || 3)));
1297
- const msizeVal = Math.max(1, Math.min(10, Math.round(Number(msize) || 1)));
1298
- const mloopsVal = Math.max(
1299
- 1,
1300
- Math.min(10, Math.round(Number(mloops) || 1)),
1301
- );
1302
- const cleanup = ['on-success', 'always', 'never'].includes(cleanupPolicy)
1303
- ? cleanupPolicy
1304
- : 'on-success';
1305
-
1306
- const args = ['.claude/worca/scripts/run_multi.py'];
1307
- args.push('--max-parallel', String(maxP));
1308
- args.push('--cleanup', cleanup);
1309
- args.push('--msize', String(msizeVal));
1310
- args.push('--mloops', String(mloopsVal));
1311
- if (baseBranch) args.push('--base-branch', baseBranch);
1312
- args.push('--requests', ...requests.map((r) => r.trim()));
1313
-
1314
- const env = { ...process.env };
1315
- delete env.CLAUDECODE;
1316
-
1317
- try {
1318
- const child = spawn('python3', args, {
1319
- detached: true,
1320
- stdio: 'ignore',
1321
- cwd: projectRoot,
1322
- env,
1323
- });
1324
- child.unref();
1325
-
1326
- const { broadcast } = req.app.locals;
1327
- if (broadcast)
1328
- broadcast('multi-pipeline-started', {
1329
- pid: child.pid,
1330
- count: requests.length,
1331
- });
1332
-
1333
- res.json({ ok: true, pid: child.pid, count: requests.length });
1334
- } catch (err) {
1335
- res.status(500).json({ ok: false, error: err.message });
1336
- }
1337
- });
1338
-
1339
- // POST /api/projects/:projectId/pipelines/:runId/stop — stop a parallel pipeline
1253
+ // POST /api/projects/:projectId/pipelines/:runId/stopstop a worktree pipeline
1340
1254
  router.post('/pipelines/:runId/stop', requireWorcaDir, (req, res) => {
1341
1255
  const runId = req.params.runId;
1342
1256
  if (!validateRunId(runId)) {
@@ -1385,7 +1299,7 @@ export function createProjectScopedRoutes({
1385
1299
  }
1386
1300
  });
1387
1301
 
1388
- // POST /api/projects/:projectId/pipelines/:runId/pause — pause a parallel pipeline
1302
+ // POST /api/projects/:projectId/pipelines/:runId/pause — pause a worktree pipeline
1389
1303
  router.post('/pipelines/:runId/pause', requireWorcaDir, (req, res) => {
1390
1304
  const runId = req.params.runId;
1391
1305
  if (!validateRunId(runId)) {
@@ -1642,5 +1556,7 @@ export function createProjectScopedRoutes({
1642
1556
  }
1643
1557
  });
1644
1558
 
1559
+ router.use('/worktrees', requireWorcaDir, createWorktreesRouter());
1560
+
1645
1561
  return router;
1646
1562
  }
@@ -10,7 +10,6 @@
10
10
 
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
- import { MultiWatcher } from './multi-watcher.js';
14
13
  import { createBeadsWatcher } from './ws-beads-watcher.js';
15
14
  import { createEventWatcher } from './ws-event-watcher.js';
16
15
  import { createLogWatcher } from './ws-log-watcher.js';
@@ -32,14 +31,12 @@ export class WatcherSet {
32
31
  this._deps = deps;
33
32
  this._closed = false;
34
33
  this._tier = TIER_POLLING;
35
- this._skipMultiWatcher = !!factoryOverrides._skipMultiWatcher;
36
- const { _skipMultiWatcher, ...factories } = factoryOverrides;
37
34
  this._factories = {
38
35
  createStatusWatcher,
39
36
  createLogWatcher,
40
37
  createBeadsWatcher,
41
38
  createEventWatcher,
42
- ...factories,
39
+ ...factoryOverrides,
43
40
  };
44
41
 
45
42
  /** @type {ReturnType<typeof createStatusWatcher> | null} */
@@ -50,8 +47,6 @@ export class WatcherSet {
50
47
  this.beadsWatcher = null;
51
48
  /** @type {ReturnType<typeof createEventWatcher> | null} */
52
49
  this.eventWatcher = null;
53
- /** @type {MultiWatcher | null} */
54
- this.multiWatcher = null;
55
50
  }
56
51
 
57
52
  get worcaDir() {
@@ -93,28 +88,6 @@ export class WatcherSet {
93
88
  if (this._tier === TIER_FULL) {
94
89
  this._createSecondaryWatchers();
95
90
  }
96
- // Start multi-pipeline watcher (skip for pipeline-level WatcherSets to avoid recursion)
97
- if (!this._skipMultiWatcher) {
98
- this._createMultiWatcher();
99
- }
100
- }
101
-
102
- /** Create multi-pipeline watcher for this project's .worca/multi/pipelines.d/. */
103
- _createMultiWatcher() {
104
- try {
105
- this.multiWatcher = new MultiWatcher(
106
- this.projectId,
107
- this._worcaDir,
108
- this._deps,
109
- );
110
- this.multiWatcher.start();
111
- } catch (err) {
112
- console.error(
113
- `[WatcherSet:${this.projectId}] multiWatcher failed:`,
114
- err.message,
115
- );
116
- this.multiWatcher = null;
117
- }
118
91
  }
119
92
 
120
93
  /** Create status watcher (always needed). */
@@ -153,8 +126,8 @@ export class WatcherSet {
153
126
  try {
154
127
  this.logWatcher = this._factories.createLogWatcher({
155
128
  broadcaster,
156
- resolveActiveRunDir: this.statusWatcher
157
- ? this.statusWatcher.resolveActiveRunDir
129
+ resolveLatestRunDir: this.statusWatcher
130
+ ? this.statusWatcher.resolveLatestRunDir
158
131
  : () => worcaDir,
159
132
  worcaDir,
160
133
  currentActiveRunId: this.statusWatcher
@@ -236,15 +209,6 @@ export class WatcherSet {
236
209
  if (this._closed) return;
237
210
  this._closed = true;
238
211
 
239
- if (this.multiWatcher) {
240
- try {
241
- this.multiWatcher.destroy();
242
- } catch {
243
- // ignore cleanup errors
244
- }
245
- this.multiWatcher = null;
246
- }
247
-
248
212
  for (const w of [
249
213
  this.statusWatcher,
250
214
  this.logWatcher,
@@ -274,11 +238,6 @@ export class WatcherSet {
274
238
  return count;
275
239
  }
276
240
 
277
- /** Get multi-pipeline watcher (may be null for pipeline-level WatcherSets). */
278
- getMultiWatcher() {
279
- return this.multiWatcher;
280
- }
281
-
282
241
  /** Delegate to status watcher's scheduleRefresh. */
283
242
  scheduleRefresh() {
284
243
  this.statusWatcher?.scheduleRefresh();