bullswarm 0.22.0 → 0.22.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.22.1 — unified workflow dashboard navigation
4
+
5
+ - The workflow dashboard now keeps V2 runs in the unified list and timeline
6
+ shell on both desktop and narrow terminals, with stable mobile borders and
7
+ the same phase-segmented timeline for current and historical runs.
8
+
9
+ - Timeline phases are numbered and presented in declared program order, so
10
+ parallel workers finishing out of order cannot place Phase 2 above Phase 1.
11
+ Preflight is selectable and opens the Workflow Planner; Up and Down move
12
+ through Preflight, Phase 1, Phase 2, and later phases, while Enter or Right
13
+ opens the selected planner or phase agents.
14
+
3
15
  ## 0.22.0 — autonomous Dynamic Workflow V2
4
16
 
5
17
  - Autonomous goals now run on the V2 kernel: agents propose bounded programs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,10 @@ function keyPressed(name, key) {
40
40
  return DASHBOARD_KEYS[name].bindings?.includes(key) ?? false;
41
41
  }
42
42
 
43
- function navigationFooter({ list = false, depth = 0, narrow = false, filterEditing = false } = {}) {
43
+ function navigationFooter({
44
+ list = false, depth = 0, narrow = false, filterEditing = false, mobileTimeline = true,
45
+ timelineSelection = null,
46
+ } = {}) {
44
47
  if (filterEditing) return 'Filter: {query}█ · Enter apply · Esc clear';
45
48
  if (list) {
46
49
  return narrow
@@ -48,8 +51,13 @@ function navigationFooter({ list = false, depth = 0, narrow = false, filterEditi
48
51
  : `${keyHint('up')} · ${keyHint('in')} · / filter · a active/all · ${keyHint('detach')} · ${keyHint('out')} · ${keyHint('nextWorkflow')} · ${keyHint('previousWorkflow')} · r refresh · c stop`;
49
52
  }
50
53
  const extras = depth >= 4 ? ' · PgUp/PgDn scroll' : '';
51
- const phaseToggle = depth <= 2 ? ' · t phases · t timeline' : '';
52
- return `${keyHint('up')} · ${keyHint('down')}${phaseToggle} · ${keyHint('in')} · ${keyHint('out')} · ${keyHint('nextWorkflow')} · ${keyHint('previousWorkflow')} · o planner · v technical · c stop · ${keyHint('detach')}${extras}`;
54
+ const phaseNavigation = narrow && depth <= 2 && mobileTimeline;
55
+ const phaseToggle = narrow && depth <= 2
56
+ ? ` · t ${mobileTimeline ? 'phases' : 'timeline'}`
57
+ : '';
58
+ const movement = phaseNavigation ? '↑ previous phase · ↓ next phase' : `${keyHint('up')} · ${keyHint('down')}`;
59
+ const open = phaseNavigation ? `Enter ${timelineSelection === 0 ? 'planner' : 'agents'}` : keyHint('in');
60
+ return `${movement}${phaseToggle} · ${open} · ${keyHint('out')} · ${keyHint('nextWorkflow')} · ${keyHint('previousWorkflow')} · o planner · v technical · c stop · ${keyHint('detach')}${extras}`;
53
61
  }
54
62
 
55
63
  function breadcrumbSegments(row, { depth = 0, phase = null, agent = null } = {}) {
@@ -146,8 +154,8 @@ export function dashboardRows(bullswarmDir, { all = false } = {}) {
146
154
  .filter((r) => all || r.ongoing)
147
155
  .sort((a, b) => {
148
156
  if (a.ongoing !== b.ongoing) return a.ongoing ? -1 : 1;
149
- return String(b.state?.startedAt ?? b.report?.startedAt ?? '')
150
- .localeCompare(String(a.state?.startedAt ?? a.report?.startedAt ?? ''));
157
+ return String(stateStartedAt(b.state) ?? b.report?.startedAt ?? '')
158
+ .localeCompare(String(stateStartedAt(a.state) ?? a.report?.startedAt ?? ''));
151
159
  })
152
160
  .map((r) => {
153
161
  const state = r.state ?? {};
@@ -779,7 +787,7 @@ export function renderWorkflowTui(row, {
779
787
  width = 120, height = 36, focus = 0, phaseIndex = null, agentIndex = null,
780
788
  detailScroll = 0, message = null, confirmCancel = false,
781
789
  controlSelected = false, orchestratorDetail = false, orchestratorVerbose = false,
782
- workflowVerbose = false, mobileTimeline = true,
790
+ workflowVerbose = false, mobileTimeline = true, timelineSelection = null,
783
791
  spinnerFrame = 0,
784
792
  } = {}) {
785
793
  width = Math.max(20, Number(width) || 120);
@@ -809,7 +817,12 @@ export function renderWorkflowTui(row, {
809
817
  ];
810
818
  const footer = confirmCancel
811
819
  ? ' Stop this workflow? y confirm · n/Esc keep running'
812
- : navigationFooter({ depth: breadcrumbDepth, narrow });
820
+ : navigationFooter({
821
+ depth: breadcrumbDepth,
822
+ narrow,
823
+ mobileTimeline: mobileTimeline && !orchestratorDetail && !workflowVerbose && focus === 0,
824
+ timelineSelection,
825
+ });
813
826
  const rawMessageLine = message
814
827
  ? ` ${truncate(message, width - 2)}`
815
828
  : ` ${orchestratorDetail ? `Workflow Planner ${orchestratorVerbose ? 'technical details' : 'overview'}` : workflowVerbose ? 'Workflow technical details' : focus === 0 ? (narrow && !mobileTimeline ? 'Phases' : 'Timeline · auto-following newest event') : focus === 1 ? 'Agents' : 'Agent activity'} · r refresh · workflow continues after detach`;
@@ -907,7 +920,13 @@ export function renderWorkflowTui(row, {
907
920
  } else if (workflowVerbose) {
908
921
  body = renderPanel('Workflow technical details', technical.slice(scroll, scroll + contentHeight), width, bodyHeight);
909
922
  } else if (focus === 0 && mobileTimeline) {
910
- body = renderWorkflowOverviewPanel(model, width, bodyHeight, spinnerFrame, detailScroll);
923
+ const selectedTimelineSegment = timelineSelection === 0
924
+ ? 'Preflight'
925
+ : timelineSelection > 0 ? model.phases[timelineSelection - 1]?.label : null;
926
+ body = renderWorkflowOverviewPanel(
927
+ model, width, bodyHeight, spinnerFrame, detailScroll,
928
+ selectedTimelineSegment,
929
+ );
911
930
  } else if (focus === 0) {
912
931
  body = renderPanel(phaseTitle, visiblePhases, width, bodyHeight);
913
932
  } else if (focus === 1) {
@@ -971,7 +990,7 @@ function joinPanels(left, right) {
971
990
  return left.map((line, index) => `${line}${right[index] ?? ''}`);
972
991
  }
973
992
 
974
- function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelineScroll = 0) {
993
+ function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelineScroll = 0, selectedTimelineSegment = null) {
975
994
  const inner = Math.max(1, width - 2);
976
995
  const timeline = workflowTimelineLines(model, inner);
977
996
  const live = workflowLiveLines(model, inner, spinnerFrame);
@@ -981,28 +1000,37 @@ function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelin
981
1000
  const liveRows = Math.min(live.lines.length, Math.max(2, Math.floor(contentRows * 0.42)));
982
1001
  const timelineRows = Math.max(1, contentRows - liveRows - nextRows);
983
1002
  const maxTimelineScroll = Math.max(0, timeline.lines.length - timelineRows);
1003
+ const selectedHeader = selectedTimelineSegment
1004
+ ? timeline.lines.findIndex((line) => line?.header && line.segment === selectedTimelineSegment)
1005
+ : -1;
984
1006
  const scroll = clamp(timelineScroll, 0, maxTimelineScroll);
985
- const end = Math.max(0, timeline.lines.length - scroll);
986
- let start = Math.max(0, end - timelineRows);
987
- if (start > 0) {
1007
+ const end = selectedHeader >= 0
1008
+ ? Math.min(timeline.lines.length, selectedHeader + timelineRows)
1009
+ : Math.max(0, timeline.lines.length - scroll);
1010
+ let start = selectedHeader >= 0
1011
+ ? selectedHeader
1012
+ : Math.max(0, end - timelineRows);
1013
+ if (start > 0 && selectedHeader < 0) {
988
1014
  // Reserve one row for the continuation header while keeping the newest
989
1015
  // timestamped milestone in view.
990
1016
  start = Math.max(0, end - Math.max(0, timelineRows - 1));
991
1017
  while (start < end && !/^\d{2}:\d{2}\s/.test(timelineText(timeline.lines[start]))) start += 1;
992
1018
  }
993
1019
  let visibleTimeline = timeline.lines.slice(start, end);
994
- if (start > 0) {
1020
+ if (start > 0 && selectedHeader < 0) {
995
1021
  visibleTimeline.unshift(dimText(`↑ ${start} earlier timeline rows`, inner));
996
1022
  const continuation = visibleTimeline.find((line) => line?.segment)?.segment
997
1023
  ?? currentTimelineSegment(model);
998
1024
  if (continuation) {
999
1025
  const priorHeader = timeline.lines.find((line) => line?.header && line.segment === continuation);
1000
- visibleTimeline.splice(1, 0, continuationHeader(
1001
- continuation,
1026
+ const header = continuationHeader(
1027
+ timelineSegmentDisplayName(continuation, model),
1002
1028
  priorHeader?.elapsed ?? 'running',
1003
1029
  inner,
1004
1030
  visibleTimeline.find((line) => line?.segment === continuation)?.at,
1005
- ));
1031
+ );
1032
+ header.segment = continuation;
1033
+ visibleTimeline.splice(1, 0, header);
1006
1034
  }
1007
1035
  // Scrolled views already carry the upward marker and continuation header;
1008
1036
  // omit inter-segment spacer rows so the viewport retains the latest event.
@@ -1013,7 +1041,7 @@ function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelin
1013
1041
  if (visibleTimeline.length >= timelineRows) visibleTimeline[visibleTimeline.length - 1] = marker;
1014
1042
  else visibleTimeline.push(marker);
1015
1043
  }
1016
- if (start > 0 && visibleTimeline.length > timelineRows) {
1044
+ if (start > 0 && selectedHeader < 0 && visibleTimeline.length > timelineRows) {
1017
1045
  // The continuation marker and header are structural context, not
1018
1046
  // expendable event rows. Keep both and trim the oldest visible events.
1019
1047
  visibleTimeline = [visibleTimeline[0], visibleTimeline[1],
@@ -1021,6 +1049,11 @@ function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelin
1021
1049
  } else {
1022
1050
  visibleTimeline = visibleTimeline.slice(0, timelineRows);
1023
1051
  }
1052
+ if (selectedTimelineSegment) {
1053
+ visibleTimeline = visibleTimeline.map((line) => line?.header && line.segment === selectedTimelineSegment
1054
+ ? { ...line, text: `\x1b[7m${timelineText(line)}\x1b[0m` }
1055
+ : line);
1056
+ }
1024
1057
  const visibleLive = live.lines.slice(0, liveRows);
1025
1058
  const visibleNext = next.slice(0, nextRows);
1026
1059
  const title = ` Workflow timeline · ${timeline.milestoneCount} milestone${timeline.milestoneCount === 1 ? '' : 's'} `;
@@ -1148,57 +1181,92 @@ function workflowTimelineLines(model, width) {
1148
1181
  if (detail) add(event.committedAt, detail, Number(event.sequence), event.type.startsWith('decision.') ? 'Planner' : 'Preflight');
1149
1182
  }
1150
1183
 
1151
- events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.sequence - b.sequence);
1184
+ return groupedTimeline(events, model, width, state.finishedAt);
1185
+ }
1186
+
1187
+ function groupedTimeline(events, model, width, workflowFinishedAt) {
1188
+ const chronological = (a, b) => Date.parse(a.at) - Date.parse(b.at)
1189
+ || Number(a.sequence ?? Number.MAX_SAFE_INTEGER) - Number(b.sequence ?? Number.MAX_SAFE_INTEGER);
1190
+ events.sort(chronological);
1191
+ // Phase blocks are a navigable program, so present adjacent phase activity in
1192
+ // the declared order even when parallel workers finish out of order. Planner
1193
+ // checkpoints remain chronological boundaries between separate programs.
1194
+ const phaseRank = new Map(model.phases.map((phase, index) => [phase.label, index]));
1195
+ const orderedEvents = [];
1196
+ for (let index = 0; index < events.length;) {
1197
+ if (!phaseRank.has(events[index].segment)) {
1198
+ orderedEvents.push(events[index]);
1199
+ index += 1;
1200
+ continue;
1201
+ }
1202
+ let end = index;
1203
+ while (end < events.length && phaseRank.has(events[end].segment)) end += 1;
1204
+ orderedEvents.push(...events.slice(index, end).sort((a, b) =>
1205
+ phaseRank.get(a.segment) - phaseRank.get(b.segment) || chronological(a, b)));
1206
+ index = end;
1207
+ }
1152
1208
  const segments = new Map();
1153
- for (const event of events) {
1209
+ for (const event of orderedEvents) {
1154
1210
  const name = event.segment ?? 'Workflow';
1155
- if (!segments.has(name)) segments.set(name, { name, events: [], first: event.at, last: event.at });
1211
+ const firstAt = event.startedAt ?? event.at;
1212
+ if (!segments.has(name)) segments.set(name, { name, events: [], first: firstAt, last: event.at });
1156
1213
  const segment = segments.get(name);
1157
1214
  segment.events.push(event);
1158
- if (Date.parse(event.at) < Date.parse(segment.first)) segment.first = event.at;
1215
+ if (Date.parse(firstAt) < Date.parse(segment.first)) segment.first = firstAt;
1159
1216
  if (Date.parse(event.at) > Date.parse(segment.last)) segment.last = event.at;
1160
1217
  }
1161
1218
  const lines = [];
1162
1219
  let previousSegment = null;
1163
1220
  const openedSegments = new Set();
1164
- for (const event of events) {
1221
+ for (const event of orderedEvents) {
1165
1222
  if (event.segment !== previousSegment) {
1166
1223
  if (lines.length) lines.push('');
1167
1224
  const segment = segments.get(event.segment ?? 'Workflow');
1168
- const finishedAt = segment.last;
1225
+ const segmentFinishedAt = segment.last;
1169
1226
  const currentSegment = currentTimelineSegment(model);
1170
- const running = !state.finishedAt && segment.name === currentSegment;
1171
- const elapsed = running ? 'running' : durationText(segment.first, finishedAt);
1172
- lines.push(openedSegments.has(segment.name)
1173
- ? continuationHeader(segment.name, elapsed, width)
1174
- : segmentHeader(segment.name, elapsed, width));
1227
+ const running = !workflowFinishedAt && segment.name === currentSegment;
1228
+ const elapsed = running ? 'running' : durationText(segment.first, segmentFinishedAt);
1229
+ const displayName = timelineSegmentDisplayName(segment.name, model);
1230
+ const header = openedSegments.has(segment.name)
1231
+ ? continuationHeader(displayName, elapsed, width)
1232
+ : segmentHeader(displayName, elapsed, width);
1233
+ header.segment = segment.name;
1234
+ lines.push(header);
1175
1235
  openedSegments.add(segment.name);
1176
1236
  previousSegment = event.segment;
1177
1237
  }
1178
1238
  lines.push(...event.lines.map((line) => ({ text: line, segment: event.segment, at: event.at })));
1179
1239
  }
1180
1240
  if (!lines.length) lines.push({ text: 'Waiting for the first durable workflow milestone', segment: null });
1181
- return { lines, milestoneCount: events.length };
1241
+ return { lines, milestoneCount: orderedEvents.length };
1242
+ }
1243
+
1244
+ function timelineSegmentDisplayName(name, model) {
1245
+ const phaseIndex = model.phases.findIndex((phase) => phase.label === name);
1246
+ return phaseIndex >= 0 ? `Phase ${phaseIndex + 1} · ${name}` : name;
1182
1247
  }
1183
1248
 
1184
1249
  function workflowTimelineLinesV2(model, width) {
1185
1250
  const { state } = model;
1186
1251
  const rows = [];
1187
- const add = (at, label, right = '', detail = null, segment = 'Workflow') => {
1252
+ const add = (at, label, right = '', detail = null, segment = 'Workflow', startedAt = null) => {
1188
1253
  if (!at) return;
1189
- rows.push({ at, segment, lines: [timelineRow(at, label, right, width), ...(detail ? [timelineDetail(detail, width)] : [])] });
1254
+ rows.push({
1255
+ at, startedAt, segment,
1256
+ lines: [timelineRow(at, label, right, width), ...(detail ? [timelineDetail(detail, width)] : [])],
1257
+ });
1190
1258
  };
1191
- add(state.lifecycle.startedAt, '● Workflow initiated', '', 'Goal accepted; preparing repository reconnaissance', 'Workflow');
1259
+ add(state.lifecycle.startedAt, '● Workflow initiated', '', 'Goal accepted; preparing repository reconnaissance', 'Preflight');
1192
1260
  const eventByType = new Map();
1193
1261
  for (const event of model.events) {
1194
1262
  if (!eventByType.has(event.type)) eventByType.set(event.type, []);
1195
1263
  eventByType.get(event.type).push(event);
1196
1264
  }
1197
- for (const event of eventByType.get('preflight.scout_started') ?? []) add(event.committedAt, '● [Preflight: Scout] started', '', event.payload?.purpose, 'Preflight');
1265
+ for (const event of eventByType.get('preflight.scout_started') ?? []) add(event.committedAt, '● Scout started', '', event.payload?.purpose, 'Preflight');
1198
1266
  for (const event of eventByType.get('preflight.scout_finished') ?? []) {
1199
1267
  const attempt = state.preflight.scout.attempts.at(-1);
1200
1268
  const detail = [attempt?.pool, attempt?.model, tokenText(attempt?.usage)].filter(Boolean).join(' · ');
1201
- add(event.committedAt, `${event.payload?.status === 'succeeded' ? '✓' : '×'} [Preflight: Scout] ${event.payload?.status === 'succeeded' ? 'completed' : 'could not complete'}`, durationText(state.preflight.scout.startedAt, state.preflight.scout.finishedAt), detail, 'Preflight');
1269
+ add(event.committedAt, `${event.payload?.status === 'succeeded' ? '✓' : '×'} Scout ${event.payload?.status === 'succeeded' ? 'completed' : 'could not complete'}`, durationText(state.preflight.scout.startedAt, state.preflight.scout.finishedAt), detail, 'Preflight');
1202
1270
  }
1203
1271
  for (const event of eventByType.get('planner.finished') ?? []) {
1204
1272
  const turn = Number(event.payload?.turn ?? 1);
@@ -1206,12 +1274,19 @@ function workflowTimelineLinesV2(model, width) {
1206
1274
  ? turn === 1 ? '[Workflow Planner] plan created' : `[Workflow Planner] plan updated #${turn}`
1207
1275
  : '[Workflow Planner] planning attempt rejected';
1208
1276
  const attempt = state.planner.attempts.findLast((item) => item.turn === turn);
1209
- add(event.committedAt, `${event.payload?.ok ? '◇' : '×'} ${label}`, attempt ? durationText(attempt.startedAt, attempt.finishedAt) : '', event.payload?.summary ?? event.payload?.why, 'Planning');
1277
+ add(
1278
+ event.committedAt,
1279
+ `${event.payload?.ok ? '◇' : '×'} ${label}`,
1280
+ attempt ? durationText(attempt.startedAt, attempt.finishedAt) : '',
1281
+ event.payload?.summary ?? event.payload?.why,
1282
+ turn === 1 ? 'Preflight' : 'Planner',
1283
+ attempt?.startedAt,
1284
+ );
1210
1285
  }
1211
1286
  const stageById = new Map((state.presentation?.stages ?? []).map((stage) => [stage.id, stage]));
1212
1287
  for (const event of model.events) {
1213
1288
  if (event.type === 'presentation.stage_started') {
1214
- add(event.committedAt, `├─ [Phase: ${event.payload.label}] started`, '', null, event.payload.label);
1289
+ add(event.committedAt, '├─ started', '', null, event.payload.label);
1215
1290
  }
1216
1291
  if (event.type === 'action.finished' || event.type === 'evidence.recorded') {
1217
1292
  const actionId = event.payload?.actionId;
@@ -1223,17 +1298,15 @@ function workflowTimelineLinesV2(model, width) {
1223
1298
  if (event.type === 'presentation.stage_completed') {
1224
1299
  const stage = stageById.get(event.payload?.stageId);
1225
1300
  const ok = event.payload?.status === 'completed';
1226
- add(event.committedAt, `└─${ok ? '✓' : '×'} [Phase: ${event.payload.label}] completed`, `${event.payload.completed}/${event.payload.total}`, null, stage?.label ?? event.payload.label);
1301
+ add(event.committedAt, `└─${ok ? '✓' : '×'} completed`, `${event.payload.completed}/${event.payload.total}`, null, stage?.label ?? event.payload.label);
1227
1302
  }
1228
1303
  }
1229
1304
  if (state.lifecycle.finishedAt) {
1230
1305
  const status = state.lifecycle.status;
1231
- add(state.lifecycle.finishedAt, `${status === 'completed' ? '✓' : status === 'partial' ? '!' : '×'} Workflow ${status === 'completed' ? 'complete - result is ready' : `${status} - result is ready`}`, durationText(state.lifecycle.startedAt, state.lifecycle.finishedAt), null, 'Workflow');
1306
+ const finalSegment = state.presentation?.stages?.findLast((stage) => stage.startedAt)?.label ?? 'Workflow';
1307
+ add(state.lifecycle.finishedAt, `${status === 'completed' ? '✓' : status === 'partial' ? '!' : '×'} Workflow ${status === 'completed' ? 'complete - result is ready' : `${status} - result is ready`}`, durationText(state.lifecycle.startedAt, state.lifecycle.finishedAt), null, finalSegment);
1232
1308
  }
1233
- rows.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
1234
- const lines = rows.flatMap((row) => row.lines.map((line) => ({ text: line, segment: row.segment, at: row.at })));
1235
- if (!lines.length) lines.push({ text: 'Waiting for the first durable workflow milestone', segment: null });
1236
- return { lines, milestoneCount: rows.length };
1309
+ return groupedTimeline(rows, model, width, state.lifecycle.finishedAt);
1237
1310
  }
1238
1311
 
1239
1312
  function segmentHeader(name, elapsed, width) {
@@ -1271,7 +1344,10 @@ function currentTimelineSegment(model) {
1271
1344
  if (model.v2) {
1272
1345
  const activeAction = state.actions.find((action) => action.status === 'running');
1273
1346
  return state.presentation.stages.find((stage) => stage.actionIds.includes(activeAction?.id))?.label
1274
- ?? (state.planner.status === 'running' ? 'Planning' : 'Workflow');
1347
+ ?? (state.preflight?.scout?.status === 'running' ? 'Preflight'
1348
+ : state.planner.status === 'running'
1349
+ ? (state.actions.length ? 'Planner' : 'Preflight')
1350
+ : 'Workflow');
1275
1351
  }
1276
1352
  if (orchestrator.active || (orchestrator.autonomous && !state.currentStep?.phase)) return 'Planner';
1277
1353
  const phase = state.currentStep?.phase ?? state.currentPhase?.name;
@@ -2019,15 +2095,21 @@ export async function runDashboard(bullswarmDir, {
2019
2095
  return 0;
2020
2096
  }
2021
2097
  let selected = 0;
2022
- let detail = Boolean(token);
2098
+ const directRow = token ? detailRow(bullswarmDir, token) : null;
2099
+ const directV2 = isV2State(directRow?.state);
2100
+ let detail = Boolean(token) && !directV2;
2023
2101
  let message = null;
2024
2102
  let lastGoodRow = null;
2025
- let dashboardFilter = 'active';
2103
+ let dashboardFilter = directV2 ? 'all' : 'active';
2026
2104
  let query = '';
2027
2105
  let filterEditing = false;
2028
2106
  let allRows = dashboardRows(bullswarmDir, { all: true });
2029
2107
  let rows = filterDashboardRows(allRows, dashboardFilter, query);
2030
- let selectedRunId = token ? detailRow(bullswarmDir, token).runId : (rows[selected]?.runId ?? null);
2108
+ if (directV2) {
2109
+ const directIndex = rows.findIndex((row) => row.runId === directRow.runId);
2110
+ if (directIndex >= 0) selected = directIndex;
2111
+ }
2112
+ let selectedRunId = token ? directRow.runId : (rows[selected]?.runId ?? null);
2031
2113
  let lastPaintedFrame = null;
2032
2114
  const ui = {
2033
2115
  focus: 0,
@@ -2042,6 +2124,7 @@ export async function runDashboard(bullswarmDir, {
2042
2124
  orchestratorVerbose: false,
2043
2125
  workflowVerbose: false,
2044
2126
  mobileTimeline: true,
2127
+ timelineSelection: null,
2045
2128
  spinnerFrame: 0,
2046
2129
  };
2047
2130
  // Clearing the entire alternate screen for every spinner frame produces a
@@ -2063,6 +2146,12 @@ export async function runDashboard(bullswarmDir, {
2063
2146
  lastPaintedFrame = frame;
2064
2147
  output.write(frame);
2065
2148
  };
2149
+ // Several mobile terminals auto-wrap when the final column is painted.
2150
+ // Leave one narrow-screen column unused so the right border remains stable.
2151
+ const frameWidth = () => {
2152
+ const columns = Math.max(20, Number(output.columns) || 120);
2153
+ return columns < 100 ? Math.max(20, columns - 1) : columns;
2154
+ };
2066
2155
  const paintUnsafe = () => {
2067
2156
  if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
2068
2157
  if (detail && selectedRunId) {
@@ -2078,7 +2167,7 @@ export async function runDashboard(bullswarmDir, {
2078
2167
  ui.phaseIndex = model.phaseIndex;
2079
2168
  ui.agentIndex = model.agentIndex;
2080
2169
  writeFrame(renderWorkflowTui(row, {
2081
- width: output.columns,
2170
+ width: frameWidth(),
2082
2171
  height: output.rows,
2083
2172
  ...ui,
2084
2173
  message,
@@ -2098,7 +2187,7 @@ export async function runDashboard(bullswarmDir, {
2098
2187
  allRows,
2099
2188
  selected,
2100
2189
  message,
2101
- width: output.columns,
2190
+ width: frameWidth(),
2102
2191
  height: output.rows,
2103
2192
  filter: dashboardFilter,
2104
2193
  query,
@@ -2115,7 +2204,7 @@ export async function runDashboard(bullswarmDir, {
2115
2204
  message = `display error: ${err.message}`;
2116
2205
  try {
2117
2206
  writeFrame(renderDashboard({
2118
- rows, allRows, selected, message, width: output.columns, height: output.rows,
2207
+ rows, allRows, selected, message, width: frameWidth(), height: output.rows,
2119
2208
  filter: dashboardFilter, query, filterEditing, spinnerFrame: ui.spinnerFrame,
2120
2209
  }));
2121
2210
  } catch { /* keep the loop alive */ }
@@ -2173,6 +2262,7 @@ export async function runDashboard(bullswarmDir, {
2173
2262
  ui.agentIndex = ui.focus < 2 ? nextModel.agentIndex
2174
2263
  : clamp(ui.agentIndex ?? nextModel.agentIndex, 0, Math.max(0, agents.length - 1));
2175
2264
  ui.detailScroll = 0;
2265
+ ui.timelineSelection = null;
2176
2266
  }
2177
2267
  message = null;
2178
2268
  paint();
@@ -2207,8 +2297,30 @@ export async function runDashboard(bullswarmDir, {
2207
2297
  const model = workflowPanelModel(row, { phaseIndex: ui.phaseIndex, agentIndex: ui.agentIndex });
2208
2298
  const narrowTimeline = output.columns < 100 && ui.mobileTimeline && ui.focus === 0;
2209
2299
  if (ui.orchestratorDetail || ui.workflowVerbose || narrowTimeline) {
2210
- if (narrowTimeline) ui.detailScroll = Math.max(0, ui.detailScroll - delta);
2211
- else ui.detailScroll = Math.max(0, ui.detailScroll + delta);
2300
+ if (narrowTimeline) {
2301
+ const visibleSegments = new Set(workflowTimelineLines(model, Math.max(20, frameWidth() - 2)).lines
2302
+ .filter((line) => line?.header)
2303
+ .map((line) => line.segment));
2304
+ const navigable = [
2305
+ ...(visibleSegments.has('Preflight') ? [{ selection: 0, phaseIndex: null }] : []),
2306
+ ...model.phases
2307
+ .map((phase, index) => ({ phase, selection: index + 1, phaseIndex: index }))
2308
+ .filter(({ phase }) => visibleSegments.has(phase.label)),
2309
+ ];
2310
+ if (navigable.length) {
2311
+ const current = navigable.findIndex((target) => target.selection === ui.timelineSelection);
2312
+ const base = current >= 0 ? current : (delta < 0 ? navigable.length : -1);
2313
+ const target = navigable[clamp(base + delta, 0, navigable.length - 1)];
2314
+ ui.timelineSelection = target.selection;
2315
+ if (target.phaseIndex != null) {
2316
+ ui.followActivePhase = false;
2317
+ ui.phaseIndex = target.phaseIndex;
2318
+ ui.agentIndex = null;
2319
+ ui.followActiveAgent = true;
2320
+ }
2321
+ ui.detailScroll = 0;
2322
+ }
2323
+ } else ui.detailScroll = Math.max(0, ui.detailScroll + delta);
2212
2324
  return paint();
2213
2325
  }
2214
2326
  if (ui.focus === 0) {
@@ -2302,12 +2414,16 @@ export async function runDashboard(bullswarmDir, {
2302
2414
  ui.orchestratorDetail = false;
2303
2415
  ui.orchestratorVerbose = false;
2304
2416
  ui.focus = 0;
2305
- ui.controlSelected = true;
2417
+ ui.controlSelected = output.columns >= 100;
2418
+ if (output.columns < 100 && ui.mobileTimeline) ui.timelineSelection = 0;
2306
2419
  ui.detailScroll = 0;
2307
2420
  } else if (ui.workflowVerbose) {
2308
2421
  ui.workflowVerbose = false;
2309
2422
  ui.detailScroll = 0;
2310
- } else if (detail && ui.focus > 0) ui.focus -= 1;
2423
+ } else if (detail && ui.focus > 0) {
2424
+ ui.focus -= 1;
2425
+ if (ui.focus === 0 && output.columns < 100 && ui.mobileTimeline) ui.timelineSelection = (ui.phaseIndex ?? 0) + 1;
2426
+ }
2311
2427
  else if (detail) detail = false;
2312
2428
  return paint();
2313
2429
  }
@@ -2320,14 +2436,25 @@ export async function runDashboard(bullswarmDir, {
2320
2436
  detail = true;
2321
2437
  ui.followActivePhase = true;
2322
2438
  ui.followActiveAgent = true;
2439
+ ui.timelineSelection = null;
2323
2440
  }
2324
2441
  } else if (ui.orchestratorDetail) {
2325
2442
  message = 'Planner detail is the deepest level.';
2326
2443
  } else if (ui.workflowVerbose) {
2327
2444
  message = 'Technical details are the deepest level.';
2328
2445
  } else if (ui.focus === 0) {
2329
- ui.focus = 1;
2330
- ui.followActiveAgent = true;
2446
+ if (output.columns < 100 && ui.mobileTimeline && ui.timelineSelection === 0) {
2447
+ const row = detailRow(bullswarmDir, selectedRunId);
2448
+ const model = workflowPanelModel(row, { phaseIndex: ui.phaseIndex, agentIndex: ui.agentIndex });
2449
+ if (model.orchestrator.autonomous) {
2450
+ ui.orchestratorDetail = true;
2451
+ ui.orchestratorVerbose = false;
2452
+ ui.controlSelected = true;
2453
+ } else message = 'This workflow has no autonomous Workflow Planner.';
2454
+ } else {
2455
+ ui.focus = 1;
2456
+ ui.followActiveAgent = true;
2457
+ }
2331
2458
  } else if (ui.focus === 1) {
2332
2459
  const row = detailRow(bullswarmDir, selectedRunId);
2333
2460
  if (workflowPanelModel(row, { phaseIndex: ui.phaseIndex }).agents.length) ui.focus = 2;
@@ -2341,10 +2468,13 @@ export async function runDashboard(bullswarmDir, {
2341
2468
  ui.orchestratorDetail = false;
2342
2469
  ui.orchestratorVerbose = false;
2343
2470
  ui.focus = 0;
2471
+ ui.controlSelected = output.columns >= 100;
2472
+ if (output.columns < 100 && ui.mobileTimeline) ui.timelineSelection = 0;
2344
2473
  } else if (ui.workflowVerbose) {
2345
2474
  ui.workflowVerbose = false;
2346
2475
  } else if (detail && ui.focus > 0) {
2347
2476
  ui.focus -= 1;
2477
+ if (ui.focus === 0 && output.columns < 100 && ui.mobileTimeline) ui.timelineSelection = (ui.phaseIndex ?? 0) + 1;
2348
2478
  } else if (detail) {
2349
2479
  detail = false;
2350
2480
  }
@@ -2354,8 +2484,8 @@ export async function runDashboard(bullswarmDir, {
2354
2484
  if (keyPressed('up', key)) return moveVertical(-1);
2355
2485
  if (keyPressed('down', key)) return moveVertical(1);
2356
2486
  const timelineScroll = detail && ui.focus === 0 && !ui.orchestratorDetail && !ui.workflowVerbose;
2357
- if (key === '\u001b[5~') { ui.detailScroll = timelineScroll ? ui.detailScroll + 8 : Math.max(0, ui.detailScroll - 8); return paint(); }
2358
- if (key === '\u001b[6~') { ui.detailScroll = timelineScroll ? Math.max(0, ui.detailScroll - 8) : ui.detailScroll + 8; return paint(); }
2487
+ if (key === '\u001b[5~') { if (timelineScroll) ui.timelineSelection = null; ui.detailScroll = timelineScroll ? ui.detailScroll + 8 : Math.max(0, ui.detailScroll - 8); return paint(); }
2488
+ if (key === '\u001b[6~') { if (timelineScroll) ui.timelineSelection = null; ui.detailScroll = timelineScroll ? Math.max(0, ui.detailScroll - 8) : ui.detailScroll + 8; return paint(); }
2359
2489
  if (key === 'o' && detail) {
2360
2490
  const row = detailRow(bullswarmDir, selectedRunId);
2361
2491
  const model = workflowPanelModel(row, { phaseIndex: ui.phaseIndex, agentIndex: ui.agentIndex });