opencode-dashboard 0.1.0 → 0.2.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/server/state.ts CHANGED
@@ -56,6 +56,198 @@ interface SerializedDashboardState {
56
56
  projects: [string, SerializedProjectState][];
57
57
  }
58
58
 
59
+ // --- Constants for dynamic column creation & visibility ---
60
+
61
+ /** Stages that are bookends and should never get dynamic columns */
62
+ const BOOKEND_STAGES = new Set(["ready", "done", "error"]);
63
+
64
+ /** Bookend column IDs that are always visible */
65
+ const ALWAYS_VISIBLE_COLUMNS = BOOKEND_STAGES;
66
+
67
+ /** Pipeline stage IDs — all pipeline agent columns including orchestrator */
68
+ const PIPELINE_STAGE_IDS = new Set([
69
+ "orchestrator",
70
+ "pipeline-builder",
71
+ "pipeline-refactor",
72
+ "pipeline-reviewer",
73
+ "pipeline-committer",
74
+ ]);
75
+
76
+ /** Grace period in milliseconds before hiding pipeline columns */
77
+ const PIPELINE_HIDE_GRACE_MS = 30_000;
78
+
79
+ /** Pipeline agent IDs with their fixed order */
80
+ const PIPELINE_AGENT_ORDER: Record<string, number> = {
81
+ orchestrator: 1,
82
+ "pipeline-builder": 2,
83
+ "pipeline-refactor": 3,
84
+ "pipeline-reviewer": 4,
85
+ "pipeline-committer": 5,
86
+ };
87
+
88
+ /** Default color palette for dynamically created agent columns */
89
+ const DEFAULT_COLUMN_COLORS = [
90
+ "#8b5cf6", // violet
91
+ "#3b82f6", // blue
92
+ "#06b6d4", // cyan
93
+ "#f59e0b", // amber
94
+ "#10b981", // emerald
95
+ "#ec4899", // pink
96
+ "#f97316", // orange
97
+ "#6366f1", // indigo
98
+ ];
99
+
100
+ /**
101
+ * Format agent name into a human-readable label.
102
+ * Strips 'pipeline-' prefix and capitalizes the first letter.
103
+ *
104
+ * Examples:
105
+ * 'pipeline-builder' → 'Builder'
106
+ * 'build' → 'Build'
107
+ * 'my-custom-agent' → 'My-custom-agent'
108
+ */
109
+ export function formatAgentLabel(name: string): string {
110
+ const display = name.startsWith("pipeline-")
111
+ ? name.slice("pipeline-".length)
112
+ : name;
113
+ return display.charAt(0).toUpperCase() + display.slice(1);
114
+ }
115
+
116
+ /**
117
+ * Check if a column with the given ID already exists in a project's columns.
118
+ */
119
+ export function hasColumn(project: ProjectState, stageId: string): boolean {
120
+ return project.columns.some((col) => col.id === stageId);
121
+ }
122
+
123
+ /**
124
+ * Pick a color from the default palette, avoiding colors already used
125
+ * by existing columns.
126
+ */
127
+ export function pickColor(project: ProjectState, _stageId: string): string {
128
+ const usedColors = new Set(project.columns.map((col) => col.color));
129
+ for (const color of DEFAULT_COLUMN_COLORS) {
130
+ if (!usedColors.has(color)) {
131
+ return color;
132
+ }
133
+ }
134
+ // All colors used — cycle through palette based on column count
135
+ return DEFAULT_COLUMN_COLORS[
136
+ project.columns.length % DEFAULT_COLUMN_COLORS.length
137
+ ];
138
+ }
139
+
140
+ /**
141
+ * Compute the correct order for a new column, then re-normalize all
142
+ * order values to clean sequential integers.
143
+ *
144
+ * Pipeline agents get their fixed order (1-5).
145
+ * Standalone agents insert before "done".
146
+ * After insertion, all columns are re-ordered to sequential integers.
147
+ */
148
+ export function computeOrder(project: ProjectState, stageId: string): number {
149
+ // If it's a pipeline agent, assign its fixed order
150
+ if (stageId in PIPELINE_AGENT_ORDER) {
151
+ return PIPELINE_AGENT_ORDER[stageId];
152
+ }
153
+
154
+ // Standalone: insert before "done"
155
+ const doneCol = project.columns.find((c) => c.id === "done");
156
+ if (doneCol) {
157
+ return doneCol.order;
158
+ }
159
+
160
+ // No "done" column yet — put it at the end
161
+ return project.columns.length;
162
+ }
163
+
164
+ /**
165
+ * Re-normalize all column order values to clean sequential integers
166
+ * based on the defined ordering rules:
167
+ * 0: ready
168
+ * 1-5: pipeline agents (orchestrator, builder, refactor, reviewer, committer)
169
+ * 6+: standalone agents (alphabetical)
170
+ * second-to-last: done
171
+ * last: error
172
+ */
173
+ function renormalizeColumnOrders(project: ProjectState): void {
174
+ const ready: ColumnConfig[] = [];
175
+ const pipeline: ColumnConfig[] = [];
176
+ const standalone: ColumnConfig[] = [];
177
+ const done: ColumnConfig[] = [];
178
+ const error: ColumnConfig[] = [];
179
+
180
+ for (const col of project.columns) {
181
+ if (col.id === "ready") {
182
+ ready.push(col);
183
+ } else if (col.id === "done") {
184
+ done.push(col);
185
+ } else if (col.id === "error") {
186
+ error.push(col);
187
+ } else if (col.id in PIPELINE_AGENT_ORDER) {
188
+ pipeline.push(col);
189
+ } else {
190
+ standalone.push(col);
191
+ }
192
+ }
193
+
194
+ // Sort pipeline agents by their fixed order
195
+ pipeline.sort(
196
+ (a, b) =>
197
+ (PIPELINE_AGENT_ORDER[a.id] ?? 99) -
198
+ (PIPELINE_AGENT_ORDER[b.id] ?? 99)
199
+ );
200
+
201
+ // Sort standalone agents alphabetically
202
+ standalone.sort((a, b) => a.id.localeCompare(b.id));
203
+
204
+ // Reassemble and assign sequential order values
205
+ const sorted = [...ready, ...pipeline, ...standalone, ...done, ...error];
206
+ for (let i = 0; i < sorted.length; i++) {
207
+ sorted[i].order = i;
208
+ }
209
+ project.columns = sorted;
210
+ }
211
+
212
+ /**
213
+ * Create a new dynamic ColumnConfig for an unknown stage.
214
+ *
215
+ * Returns the new column, or null if:
216
+ * - The stage is a bookend ("ready", "done", "error")
217
+ * - A column with this ID already exists
218
+ */
219
+ export function createDynamicColumn(
220
+ project: ProjectState,
221
+ stageId: string
222
+ ): ColumnConfig | null {
223
+ // Don't create columns for bookend stages
224
+ if (BOOKEND_STAGES.has(stageId)) {
225
+ return null;
226
+ }
227
+
228
+ // Don't create duplicates
229
+ if (hasColumn(project, stageId)) {
230
+ return null;
231
+ }
232
+
233
+ const isPipelineAgent = stageId in PIPELINE_AGENT_ORDER;
234
+
235
+ const column: ColumnConfig = {
236
+ id: stageId,
237
+ label: formatAgentLabel(stageId),
238
+ type: "agent",
239
+ color: pickColor(project, stageId),
240
+ order: computeOrder(project, stageId),
241
+ group: isPipelineAgent ? "pipeline" : "standalone",
242
+ source: "dynamic",
243
+ };
244
+
245
+ project.columns.push(column);
246
+ renormalizeColumnOrders(project);
247
+
248
+ return column;
249
+ }
250
+
59
251
  // --- StateManager ---
60
252
 
61
253
  export class StateManager {
@@ -64,6 +256,30 @@ export class StateManager {
64
256
  private persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
65
257
  private persistDebounceMs: number;
66
258
 
259
+ /**
260
+ * In-memory active agent tracking per project.
261
+ * Keyed by projectPath. The Set contains agent names that are currently active.
262
+ * Serialized as string[] on ProjectState.activeAgents for JSON output.
263
+ */
264
+ private activeAgents: Map<string, Set<string>> = new Map();
265
+
266
+ /**
267
+ * In-memory grace-period timers for pipeline column hiding.
268
+ * When pipeline columns transition from visible → should-hide, a 30s timer
269
+ * is started. If pipeline activity resumes, the timer is cancelled.
270
+ * Keyed by projectPath.
271
+ */
272
+ private pipelineHideTimers: Map<
273
+ string,
274
+ ReturnType<typeof setTimeout>
275
+ > = new Map();
276
+
277
+ /**
278
+ * Tracks the last visible column key per project to avoid unnecessary broadcasts.
279
+ * Keyed by projectPath. Value is a joined string of visible column IDs.
280
+ */
281
+ private _lastVisibleColumnsKey: Map<string, string> = new Map();
282
+
67
283
  /** Listeners called after every state mutation (for SSE broadcasting) */
68
284
  private listeners: Array<(event: string, data: unknown) => void> = [];
69
285
 
@@ -120,6 +336,10 @@ export class StateManager {
120
336
  pipelines,
121
337
  lastBeadSnapshot: project.lastBeadSnapshot,
122
338
  columns: project.columns,
339
+ visibleColumns: this.computeVisibleColumns(project),
340
+ activeAgents: Array.from(
341
+ this.activeAgents.get(project.projectPath) || []
342
+ ),
123
343
  });
124
344
  }
125
345
  return { projects };
@@ -153,6 +373,10 @@ export class StateManager {
153
373
  };
154
374
  this.state.projects.set(projectPath, project);
155
375
  }
376
+ // Ensure activeAgents Set exists for this project
377
+ if (!this.activeAgents.has(projectPath)) {
378
+ this.activeAgents.set(projectPath, new Set());
379
+ }
156
380
  this.schedulePersist();
157
381
  }
158
382
 
@@ -161,6 +385,11 @@ export class StateManager {
161
385
  for (const [, project] of this.state.projects) {
162
386
  if (project.pluginId === pluginId) {
163
387
  project.connected = false;
388
+ // Clear active agents for this project
389
+ const agents = this.activeAgents.get(project.projectPath);
390
+ if (agents) {
391
+ agents.clear();
392
+ }
164
393
  // No active session — mark active pipelines as idle
165
394
  for (const [, pipeline] of project.pipelines) {
166
395
  if (pipeline.status === "active") {
@@ -195,6 +424,199 @@ export class StateManager {
195
424
  return null;
196
425
  }
197
426
 
427
+ /** Get the active agents Set for a project (by projectPath) */
428
+ getActiveAgents(projectPath: string): Set<string> {
429
+ let agents = this.activeAgents.get(projectPath);
430
+ if (!agents) {
431
+ agents = new Set();
432
+ this.activeAgents.set(projectPath, agents);
433
+ }
434
+ return agents;
435
+ }
436
+
437
+ // --- Column Visibility ---
438
+
439
+ /**
440
+ * Check if any bead across all pipelines has a stage matching any of
441
+ * the given stage IDs.
442
+ */
443
+ anyBeadInStages(project: ProjectState, stageIds: Set<string>): boolean {
444
+ for (const [, pipeline] of project.pipelines) {
445
+ for (const [, bead] of pipeline.beads) {
446
+ if (stageIds.has(bead.stage)) {
447
+ return true;
448
+ }
449
+ }
450
+ }
451
+ return false;
452
+ }
453
+
454
+ /**
455
+ * Determine whether the pipeline group should be visible, considering
456
+ * active agents, bead stages, and grace period timers.
457
+ *
458
+ * This is a pure read — it does NOT start or cancel timers.
459
+ * Timer management is done in broadcastColumnsUpdate.
460
+ */
461
+ private isPipelineVisible(project: ProjectState): boolean {
462
+ const activeAgents = this.getActiveAgents(project.projectPath);
463
+ const orchestratorActive = activeAgents.has("orchestrator");
464
+ const anyBeadInPipeline = this.anyBeadInStages(project, PIPELINE_STAGE_IDS);
465
+
466
+ if (orchestratorActive || anyBeadInPipeline) {
467
+ return true;
468
+ }
469
+
470
+ // Check if any pipeline columns actually exist in the project
471
+ const hasPipelineColumns = project.columns.some(
472
+ (col) => col.group === "pipeline"
473
+ );
474
+ if (!hasPipelineColumns) {
475
+ return false;
476
+ }
477
+
478
+ // Pipeline should hide — but is a grace period timer running?
479
+ return this.pipelineHideTimers.has(project.projectPath);
480
+ }
481
+
482
+ /**
483
+ * Compute the set of visible columns for a project based on:
484
+ * 1. Bookend columns (ready, done, error) are always visible
485
+ * 2. Pipeline group: visible when orchestrator is active OR any bead
486
+ * occupies a pipeline stage OR grace period timer is running.
487
+ * Show/hide as a unit.
488
+ * 3. Standalone columns: visible when their agent is active OR any bead
489
+ * occupies that column's stage.
490
+ *
491
+ * This is a pure read with no side effects (does not start timers).
492
+ * Returns columns sorted by order.
493
+ */
494
+ computeVisibleColumns(project: ProjectState): ColumnConfig[] {
495
+ const activeAgents = this.getActiveAgents(project.projectPath);
496
+ const pipelineVisible = this.isPipelineVisible(project);
497
+
498
+ const visible: ColumnConfig[] = [];
499
+
500
+ for (const col of project.columns) {
501
+ // 1. Always include bookend columns
502
+ if (ALWAYS_VISIBLE_COLUMNS.has(col.id)) {
503
+ visible.push(col);
504
+ continue;
505
+ }
506
+
507
+ // 2. Pipeline group columns — show/hide as a unit
508
+ if (col.group === "pipeline") {
509
+ if (pipelineVisible) {
510
+ visible.push(col);
511
+ }
512
+ continue;
513
+ }
514
+
515
+ // 3. Standalone columns — show if agent active OR any bead in that stage
516
+ const agentActive = activeAgents.has(col.id);
517
+ const beadInStage = this.anyBeadInStages(
518
+ project,
519
+ new Set([col.id])
520
+ );
521
+ if (agentActive || beadInStage) {
522
+ visible.push(col);
523
+ }
524
+ }
525
+
526
+ // Sort by order
527
+ visible.sort((a, b) => a.order - b.order);
528
+ return visible;
529
+ }
530
+
531
+ /**
532
+ * Manage grace period timers for pipeline column hiding.
533
+ *
534
+ * Called by broadcastColumnsUpdate when pipeline state changes.
535
+ * - If pipeline is active: cancel any pending hide timer
536
+ * - If pipeline should hide and was previously visible: start grace timer
537
+ * - If pipeline was never visible: no timer needed
538
+ */
539
+ private managePipelineGracePeriod(project: ProjectState): void {
540
+ const activeAgents = this.getActiveAgents(project.projectPath);
541
+ const projectPath = project.projectPath;
542
+ const orchestratorActive = activeAgents.has("orchestrator");
543
+ const anyBeadInPipeline = this.anyBeadInStages(project, PIPELINE_STAGE_IDS);
544
+ const pipelineShouldBeActive = orchestratorActive || anyBeadInPipeline;
545
+
546
+ const existingTimer = this.pipelineHideTimers.get(projectPath);
547
+
548
+ if (pipelineShouldBeActive) {
549
+ // Pipeline is active — cancel any pending hide timer
550
+ if (existingTimer) {
551
+ clearTimeout(existingTimer);
552
+ this.pipelineHideTimers.delete(projectPath);
553
+ }
554
+ return;
555
+ }
556
+
557
+ // Pipeline should hide — do we need a grace period?
558
+ if (existingTimer) {
559
+ // Timer already running — let it continue
560
+ return;
561
+ }
562
+
563
+ // Check if pipeline was previously visible (had a key with pipeline cols)
564
+ const lastKey = this._lastVisibleColumnsKey.get(projectPath) || "";
565
+ const wasPipelineVisible = [...PIPELINE_STAGE_IDS].some((id) =>
566
+ lastKey.split(",").includes(id)
567
+ );
568
+
569
+ if (wasPipelineVisible) {
570
+ // Transition from visible → should-hide: start grace period
571
+ const timer = setTimeout(() => {
572
+ this.pipelineHideTimers.delete(projectPath);
573
+ // Clear the last key so managePipelineGracePeriod doesn't
574
+ // think pipeline was previously visible and start another timer.
575
+ this._lastVisibleColumnsKey.delete(projectPath);
576
+ // Grace period elapsed — recompute and broadcast
577
+ this.broadcastColumnsUpdate(project);
578
+ }, PIPELINE_HIDE_GRACE_MS);
579
+ // Don't block process exit
580
+ if (timer && typeof timer === "object" && "unref" in timer) {
581
+ timer.unref();
582
+ }
583
+ this.pipelineHideTimers.set(projectPath, timer);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Recompute visible columns for a project and track whether they changed.
589
+ * If the visible set changed, updates the tracking key and notifies listeners.
590
+ * Also manages grace period timers for pipeline column hiding.
591
+ *
592
+ * Returns true if the visible columns changed, false otherwise.
593
+ */
594
+ broadcastColumnsUpdate(project: ProjectState): boolean {
595
+ // Manage grace period timers BEFORE computing visible columns,
596
+ // so that computeVisibleColumns sees the correct timer state.
597
+ this.managePipelineGracePeriod(project);
598
+
599
+ const visible = this.computeVisibleColumns(project);
600
+ const newKey = visible.map((c) => c.id).join(",");
601
+ const oldKey = this._lastVisibleColumnsKey.get(project.projectPath) || "";
602
+
603
+ if (newKey === oldKey) {
604
+ return false;
605
+ }
606
+
607
+ this._lastVisibleColumnsKey.set(project.projectPath, newKey);
608
+
609
+ // Notify listeners about column visibility change
610
+ for (const listener of this.listeners) {
611
+ listener("columns:visibility", {
612
+ projectPath: project.projectPath,
613
+ visibleColumns: visible,
614
+ });
615
+ }
616
+
617
+ return true;
618
+ }
619
+
198
620
  // --- Event Processing ---
199
621
 
200
622
  /**
@@ -344,6 +766,9 @@ export class StateManager {
344
766
  }
345
767
  pipeline.currentBeadId = beadId;
346
768
  pipeline.status = "active";
769
+
770
+ // Auto-create column for unknown stages
771
+ createDynamicColumn(project, stage);
347
772
  }
348
773
 
349
774
  if (beadRecord) {
@@ -351,6 +776,9 @@ export class StateManager {
351
776
  }
352
777
  this.schedulePersist();
353
778
 
779
+ // Recompute column visibility (bead claimed → stage changed)
780
+ this.broadcastColumnsUpdate(project);
781
+
354
782
  return {
355
783
  event: "bead:claimed",
356
784
  data: {
@@ -380,9 +808,15 @@ export class StateManager {
380
808
  beadState.agentSessionId = agentSessionId;
381
809
  }
382
810
  }
811
+
812
+ // Auto-create column for unknown stages
813
+ createDynamicColumn(project, stage);
383
814
  }
384
815
  this.schedulePersist();
385
816
 
817
+ // Recompute column visibility (bead stage changed)
818
+ this.broadcastColumnsUpdate(project);
819
+
386
820
  return {
387
821
  event: "bead:stage",
388
822
  data: {
@@ -422,6 +856,9 @@ export class StateManager {
422
856
  }
423
857
  this.schedulePersist();
424
858
 
859
+ // Recompute column visibility (bead done → may affect pipeline visibility)
860
+ this.broadcastColumnsUpdate(project);
861
+
425
862
  return {
426
863
  event: "bead:done",
427
864
  data: {
@@ -476,6 +913,9 @@ export class StateManager {
476
913
  }
477
914
  this.schedulePersist();
478
915
 
916
+ // Recompute column visibility (bead error → may affect column visibility)
917
+ this.broadcastColumnsUpdate(project);
918
+
479
919
  return {
480
920
  event: "bead:error",
481
921
  data: {
@@ -588,6 +1028,9 @@ export class StateManager {
588
1028
  }
589
1029
  this.schedulePersist();
590
1030
 
1031
+ // Recompute column visibility (bead removed → may affect column visibility)
1032
+ this.broadcastColumnsUpdate(project);
1033
+
591
1034
  return {
592
1035
  event: "bead:removed",
593
1036
  data: {
@@ -604,6 +1047,7 @@ export class StateManager {
604
1047
  ): { event: string; data: Record<string, unknown> } {
605
1048
  const beadId = data.beadId as string;
606
1049
  const sessionId = data.sessionId as string;
1050
+ const agentName = data.agent as string;
607
1051
  const pipeline = this.getOrCreateDefaultPipeline(project);
608
1052
 
609
1053
  if (beadId && sessionId) {
@@ -613,6 +1057,26 @@ export class StateManager {
613
1057
  }
614
1058
  }
615
1059
 
1060
+ // Track active agent
1061
+ if (agentName) {
1062
+ let agents = this.activeAgents.get(project.projectPath);
1063
+ if (!agents) {
1064
+ agents = new Set();
1065
+ this.activeAgents.set(project.projectPath, agents);
1066
+ }
1067
+ agents.add(agentName);
1068
+
1069
+ // Create dynamic column if one doesn't exist for this agent.
1070
+ // Built-in agents (Build, Plan, Explore, etc.) don't have .md config
1071
+ // files, so they won't have columns from the initial column config.
1072
+ if (!hasColumn(project, agentName)) {
1073
+ createDynamicColumn(project, agentName);
1074
+ }
1075
+ }
1076
+
1077
+ // Recompute column visibility (agent became active → may show columns)
1078
+ this.broadcastColumnsUpdate(project);
1079
+
616
1080
  return {
617
1081
  event: "agent:active",
618
1082
  data: {
@@ -629,6 +1093,7 @@ export class StateManager {
629
1093
  ): { event: string; data: Record<string, unknown> } {
630
1094
  const beadId = data.beadId as string;
631
1095
  const sessionId = data.sessionId as string;
1096
+ const agentName = data.agent as string;
632
1097
  const pipeline = this.getOrCreateDefaultPipeline(project);
633
1098
 
634
1099
  if (beadId) {
@@ -638,6 +1103,17 @@ export class StateManager {
638
1103
  }
639
1104
  }
640
1105
 
1106
+ // Remove agent from active set
1107
+ if (agentName) {
1108
+ const agents = this.activeAgents.get(project.projectPath);
1109
+ if (agents) {
1110
+ agents.delete(agentName);
1111
+ }
1112
+ }
1113
+
1114
+ // Recompute column visibility (agent went idle → may hide columns)
1115
+ this.broadcastColumnsUpdate(project);
1116
+
641
1117
  return {
642
1118
  event: "agent:idle",
643
1119
  data: {
@@ -713,16 +1189,42 @@ export class StateManager {
713
1189
  const columns = data.columns as ColumnConfig[] | undefined;
714
1190
 
715
1191
  if (Array.isArray(columns)) {
716
- project.columns = columns;
1192
+ // Merge strategy: keep dynamic columns that aren't in the new set,
1193
+ // replace/update discovered columns with the new ones from the plugin.
1194
+ // This prevents losing dynamic columns when a plugin reconnects.
1195
+ const existingDynamic = project.columns.filter(
1196
+ (col) => col.source === "dynamic"
1197
+ );
1198
+
1199
+ // Build a set of IDs from the incoming plugin columns
1200
+ const incomingIds = new Set(columns.map((c) => c.id));
1201
+
1202
+ // Keep dynamic columns whose IDs are NOT in the incoming set
1203
+ // (if the plugin now includes a column that was previously dynamic,
1204
+ // the plugin's version takes precedence)
1205
+ const dynamicToKeep = existingDynamic.filter(
1206
+ (col) => !incomingIds.has(col.id)
1207
+ );
1208
+
1209
+ // Merge: plugin columns + retained dynamic columns
1210
+ project.columns = [...columns, ...dynamicToKeep];
1211
+
1212
+ // Re-normalize order values to account for merged columns
1213
+ renormalizeColumnOrders(project);
1214
+
717
1215
  this.schedulePersist();
718
1216
  }
719
1217
 
1218
+ // Recompute column visibility (columns changed)
1219
+ this.broadcastColumnsUpdate(project);
1220
+
720
1221
  return {
721
1222
  event: "columns:update",
722
1223
  data: {
723
1224
  ...data,
724
1225
  projectPath: project.projectPath,
725
1226
  columns: project.columns,
1227
+ visibleColumns: this.computeVisibleColumns(project),
726
1228
  },
727
1229
  };
728
1230
  }
@@ -827,6 +1329,8 @@ export class StateManager {
827
1329
  // Mark all projects as disconnected on load (plugins will re-register)
828
1330
  for (const [, project] of this.state.projects) {
829
1331
  project.connected = false;
1332
+ // Initialize empty activeAgents Set for each loaded project
1333
+ this.activeAgents.set(project.projectPath, new Set());
830
1334
  // No session running after restart — mark active pipelines as idle
831
1335
  for (const [, pipeline] of project.pipelines) {
832
1336
  if (pipeline.status === "active") {
@@ -923,11 +1427,24 @@ export class StateManager {
923
1427
  destroy(): void {
924
1428
  this.persistNow();
925
1429
  this.listeners = [];
1430
+ // Clear all pipeline hide timers
1431
+ for (const timer of this.pipelineHideTimers.values()) {
1432
+ clearTimeout(timer);
1433
+ }
1434
+ this.pipelineHideTimers.clear();
1435
+ this._lastVisibleColumnsKey.clear();
926
1436
  }
927
1437
 
928
1438
  /** Reset all state (for testing) */
929
1439
  clear(): void {
930
1440
  this.state = { projects: new Map() };
1441
+ this.activeAgents.clear();
1442
+ // Clear all pipeline hide timers
1443
+ for (const timer of this.pipelineHideTimers.values()) {
1444
+ clearTimeout(timer);
1445
+ }
1446
+ this.pipelineHideTimers.clear();
1447
+ this._lastVisibleColumnsKey.clear();
931
1448
  if (this.persistDebounceTimer) {
932
1449
  clearTimeout(this.persistDebounceTimer);
933
1450
  this.persistDebounceTimer = null;