@pi-agents/orchid 0.1.0-beta.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Output formatting, dashboard widget, wave plan display
3
+ * @module orch/formatting
4
+ */
5
+ import { join } from "path";
6
+ import { truncateToWidth } from "@earendil-works/pi-tui";
7
+
8
+ import { parseDependencyReference } from "./discovery.ts";
9
+ import type {
10
+ LaneAssignment,
11
+ MonitorState,
12
+ OrchBatchRuntimeState,
13
+ OrchDashboardViewModel,
14
+ OrchLaneCardData,
15
+ OrchSummaryCounts,
16
+ ParsedTask,
17
+ WaveComputationResult,
18
+ } from "./types.ts";
19
+ import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts";
20
+
21
+ // โ”€โ”€ Wave Output Formatting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
22
+
23
+ // โ”€โ”€ Dependency Graph Formatting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
24
+
25
+ /**
26
+ * Format a dependency graph for display.
27
+ *
28
+ * Shows both upstream (what each task depends on) and downstream
29
+ * (what depends on each task) views. Output is deterministic:
30
+ * tasks sorted by ID, edges sorted by target ID.
31
+ *
32
+ * If `filterTaskId` is provided, only shows edges involving that task.
33
+ */
34
+ export function formatDependencyGraph(
35
+ pending: Map<string, ParsedTask>,
36
+ completed: Set<string>,
37
+ filterTaskId?: string,
38
+ ): string {
39
+ const lines: string[] = [];
40
+
41
+ // Sort tasks deterministically by ID
42
+ const sortedTasks = [...pending.values()].sort((a, b) => a.taskId.localeCompare(b.taskId));
43
+
44
+ // Build downstream index: taskID โ†’ tasks that depend on it
45
+ const downstream = new Map<string, string[]>();
46
+ for (const task of sortedTasks) {
47
+ for (const depRaw of task.dependencies) {
48
+ const depId = parseDependencyReference(depRaw).taskId;
49
+ const existing = downstream.get(depId) || [];
50
+ existing.push(task.taskId);
51
+ downstream.set(depId, existing);
52
+ }
53
+ }
54
+
55
+ // If filtering to a single task
56
+ if (filterTaskId) {
57
+ const task = pending.get(filterTaskId);
58
+ if (!task) {
59
+ lines.push(`โŒ Task "${filterTaskId}" not found in pending tasks.`);
60
+ return lines.join("\n");
61
+ }
62
+
63
+ lines.push(`๐Ÿ”— Dependencies for ${filterTaskId} (${task.taskName}):`);
64
+ lines.push("");
65
+
66
+ // Upstream: what this task depends on
67
+ lines.push(" โฌ† Upstream (depends on):");
68
+ if (task.dependencies.length === 0) {
69
+ lines.push(" (none โ€” no dependencies)");
70
+ } else {
71
+ const sortedDeps = [...task.dependencies].sort();
72
+ for (const depRaw of sortedDeps) {
73
+ const depId = parseDependencyReference(depRaw).taskId;
74
+ const status = completed.has(depId)
75
+ ? "โœ… complete"
76
+ : pending.has(depId)
77
+ ? "โณ pending"
78
+ : "โ“ unknown";
79
+ lines.push(` ${filterTaskId} โ†’ ${depRaw} (${status})`);
80
+ }
81
+ }
82
+
83
+ // Downstream: what depends on this task
84
+ lines.push("");
85
+ lines.push(" โฌ‡ Downstream (depended on by):");
86
+ const downstreamTasks = (downstream.get(filterTaskId) || []).sort();
87
+ if (downstreamTasks.length === 0) {
88
+ lines.push(" (none โ€” no tasks depend on this)");
89
+ } else {
90
+ for (const dep of downstreamTasks) {
91
+ lines.push(` ${dep} โ†’ ${filterTaskId}`);
92
+ }
93
+ }
94
+
95
+ return lines.join("\n");
96
+ }
97
+
98
+ // Full graph view
99
+ lines.push("๐Ÿ”— Dependency Graph:");
100
+ lines.push("");
101
+
102
+ let hasDeps = false;
103
+
104
+ // Section 1: Upstream view (what each task depends on)
105
+ lines.push(" โฌ† Upstream (task โ†’ depends on):");
106
+ for (const task of sortedTasks) {
107
+ if (task.dependencies.length > 0) {
108
+ hasDeps = true;
109
+ const sortedDeps = [...task.dependencies].sort();
110
+ for (const depRaw of sortedDeps) {
111
+ const depId = parseDependencyReference(depRaw).taskId;
112
+ const status = completed.has(depId)
113
+ ? "โœ… complete"
114
+ : pending.has(depId)
115
+ ? "โณ pending"
116
+ : "โ“ unknown";
117
+ lines.push(` ${task.taskId} โ†’ ${depRaw} (${status})`);
118
+ }
119
+ }
120
+ }
121
+ if (!hasDeps) {
122
+ lines.push(" (none โ€” all tasks are independent)");
123
+ }
124
+
125
+ // Section 2: Downstream view (what depends on each task)
126
+ lines.push("");
127
+ lines.push(" โฌ‡ Downstream (task โ† depended on by):");
128
+ let hasDownstream = false;
129
+ const allTargets = new Set<string>();
130
+ for (const task of sortedTasks) {
131
+ for (const depRaw of task.dependencies) {
132
+ allTargets.add(parseDependencyReference(depRaw).taskId);
133
+ }
134
+ }
135
+ const sortedTargets = [...allTargets].sort();
136
+ for (const target of sortedTargets) {
137
+ const dependents = (downstream.get(target) || []).sort();
138
+ if (dependents.length > 0) {
139
+ hasDownstream = true;
140
+ const status = completed.has(target) ? "โœ…" : pending.has(target) ? "โณ" : "โ“";
141
+ lines.push(` ${target} ${status} โ† ${dependents.join(", ")}`);
142
+ }
143
+ }
144
+ if (!hasDownstream) {
145
+ lines.push(" (none โ€” no downstream dependencies)");
146
+ }
147
+
148
+ // Section 3: Independent tasks (no deps, nothing depends on them)
149
+ const independentTasks = sortedTasks.filter(
150
+ (t) => t.dependencies.length === 0 && !downstream.get(t.taskId)?.length,
151
+ );
152
+ if (independentTasks.length > 0) {
153
+ lines.push("");
154
+ lines.push(" โ—‹ Independent (no dependencies, nothing depends on them):");
155
+ for (const task of independentTasks) {
156
+ lines.push(` ${task.taskId} [${task.size}] ${task.taskName}`);
157
+ }
158
+ }
159
+
160
+ return lines.join("\n");
161
+ }
162
+
163
+ /**
164
+ * Format wave computation results as a readable execution plan.
165
+ *
166
+ * Output sections (fixed order):
167
+ * 1. Wave overview header
168
+ * 2. Per-wave: task count, lane count, parallel/serial indicator
169
+ * 3. Per-lane within wave: tasks with sizes, serial notes, lane weight
170
+ * 4. Per-wave: estimated duration (critical path = max lane duration)
171
+ * 5. Summary: total estimated duration, size-to-duration table
172
+ *
173
+ * Duration calculation:
174
+ * - Per lane: sum of task durations for tasks in that lane
175
+ * - Per wave: max lane duration (parallel bottleneck / critical path)
176
+ * - Total: sum of wave durations (waves run sequentially)
177
+ */
178
+ export function formatWavePlan(
179
+ result: WaveComputationResult,
180
+ sizeWeights: Record<string, number>,
181
+ ): string {
182
+ const lines: string[] = [];
183
+
184
+ if (result.errors.length > 0) {
185
+ lines.push("โŒ Wave Computation Errors:");
186
+ for (const err of result.errors) {
187
+ lines.push(` [${err.code}] ${err.message}`);
188
+ }
189
+ return lines.join("\n");
190
+ }
191
+
192
+ if (result.waves.length === 0) {
193
+ lines.push("No waves to schedule.");
194
+ return lines.join("\n");
195
+ }
196
+
197
+ // Count total tasks
198
+ const totalTasks = result.waves.reduce((sum, w) => sum + w.tasks.length, 0);
199
+ const maxLanesUsed = Math.max(
200
+ ...result.waves.map((w) => {
201
+ const lanes = new Set(w.tasks.map((t) => t.lane));
202
+ return lanes.size;
203
+ }),
204
+ );
205
+
206
+ lines.push(
207
+ `๐ŸŒŠ Execution Plan: ${result.waves.length} wave(s), ` +
208
+ `${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`,
209
+ );
210
+ lines.push("");
211
+
212
+ let totalEstimate = 0;
213
+ for (const wave of result.waves) {
214
+ // Group tasks by lane (deterministic: Map preserves insertion order)
215
+ const laneGroups = new Map<number, LaneAssignment[]>();
216
+ for (const assignment of wave.tasks) {
217
+ const existing = laneGroups.get(assignment.lane) || [];
218
+ existing.push(assignment);
219
+ laneGroups.set(assignment.lane, existing);
220
+ }
221
+
222
+ const laneCount = laneGroups.size;
223
+ const taskCount = wave.tasks.length;
224
+ const parallel = laneCount > 1 ? "parallel" : "serial";
225
+
226
+ lines.push(
227
+ ` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` + `${laneCount} lane(s) [${parallel}]`,
228
+ );
229
+
230
+ // Calculate wave duration: critical path = max lane duration
231
+ let maxLaneDuration = 0;
232
+
233
+ // Sort lanes deterministically by lane number
234
+ const sortedLanes = [...laneGroups.entries()].sort((a, b) => a[0] - b[0]);
235
+
236
+ for (const [lane, assignments] of sortedLanes) {
237
+ // Sort tasks within lane by task ID for deterministic output
238
+ const sortedAssignments = [...assignments].sort((a, b) => a.taskId.localeCompare(b.taskId));
239
+ const taskList = sortedAssignments.map((a) => `${a.taskId} [${a.task.size}]`).join(", ");
240
+ const laneDuration = sortedAssignments.reduce(
241
+ (sum, a) => sum + getTaskDurationMinutes(a.task.size, sizeWeights),
242
+ 0,
243
+ );
244
+ if (laneDuration > maxLaneDuration) maxLaneDuration = laneDuration;
245
+ const serialNote = sortedAssignments.length > 1 ? " (serial)" : "";
246
+ lines.push(` Lane ${lane}: ${taskList}${serialNote} ` + `[est. ${laneDuration} min]`);
247
+ }
248
+
249
+ // Critical path for this wave
250
+ totalEstimate += maxLaneDuration;
251
+ lines.push(` โฑ Wave duration: ${maxLaneDuration} min ` + `(critical path: longest lane)`);
252
+ lines.push("");
253
+ }
254
+
255
+ // Summary with size-to-duration table
256
+ const totalHours = (totalEstimate / 60).toFixed(1);
257
+ lines.push(`๐Ÿ“Š Total estimated duration: ${totalEstimate} min (~${totalHours} hours)`);
258
+ lines.push(
259
+ ` Duration model: S=${SIZE_DURATION_MINUTES["S"]}m, ` +
260
+ `M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`,
261
+ );
262
+ lines.push(
263
+ " Critical path: sum of per-wave bottleneck lanes " + "(waves sequential, lanes parallel)",
264
+ );
265
+
266
+ return lines.join("\n");
267
+ }
268
+
269
+ // โ”€โ”€ Summary Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
270
+
271
+ /**
272
+ * Compute summary counts from batch state + optional monitor state.
273
+ *
274
+ * Pure function โ€” no side effects, deterministic output.
275
+ */
276
+ export function computeOrchSummaryCounts(
277
+ batchState: OrchBatchRuntimeState,
278
+ monitorState?: MonitorState | null,
279
+ ): OrchSummaryCounts {
280
+ let running = 0;
281
+ let stalled = 0;
282
+
283
+ // If we have live monitor data, count running/stalled from it
284
+ if (monitorState) {
285
+ for (const lane of monitorState.lanes) {
286
+ if (lane.currentTaskSnapshot) {
287
+ if (lane.currentTaskSnapshot.status === "stalled") {
288
+ stalled++;
289
+ } else if (lane.currentTaskSnapshot.status === "running") {
290
+ running++;
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ const completed = batchState.succeededTasks;
297
+ const failed = batchState.failedTasks;
298
+ const blocked = batchState.blockedTasks;
299
+ const total = batchState.totalTasks;
300
+ const queued = Math.max(
301
+ 0,
302
+ total - completed - failed - blocked - stalled - running - batchState.skippedTasks,
303
+ );
304
+
305
+ return { completed, running, queued, failed, blocked, stalled, total };
306
+ }
307
+
308
+ /**
309
+ * Format elapsed time from start/end timestamps.
310
+ *
311
+ * @param startMs - Start epoch ms
312
+ * @param endMs - End epoch ms (null = use current time)
313
+ * @returns Human-readable string, e.g., "2m 14s" or "1h 5m 30s"
314
+ */
315
+ export function formatElapsedTime(startMs: number, endMs?: number | null): string {
316
+ if (startMs <= 0) return "0s";
317
+ const elapsed = (endMs ?? Date.now()) - startMs;
318
+ if (elapsed < 0) return "0s";
319
+
320
+ const totalSec = Math.floor(elapsed / 1000);
321
+ const hours = Math.floor(totalSec / 3600);
322
+ const minutes = Math.floor((totalSec % 3600) / 60);
323
+ const seconds = totalSec % 60;
324
+
325
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
326
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
327
+ return `${seconds}s`;
328
+ }
329
+
330
+ /**
331
+ * Build the dashboard view-model from runtime state.
332
+ *
333
+ * Pure function โ€” deterministic mapping from OrchBatchRuntimeState +
334
+ * optional MonitorState to render-ready OrchDashboardViewModel.
335
+ *
336
+ * Fallback behavior:
337
+ * - No batch โ†’ idle view with zeroed counts
338
+ * - No monitor data โ†’ empty lane cards, counts from batch state only
339
+ * - Missing STATUS.md โ†’ "no data" in lane card
340
+ */
341
+ export function buildDashboardViewModel(
342
+ batchState: OrchBatchRuntimeState,
343
+ monitorState?: MonitorState | null,
344
+ ): OrchDashboardViewModel {
345
+ const summary = computeOrchSummaryCounts(batchState, monitorState);
346
+ const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt);
347
+
348
+ const waveProgress =
349
+ batchState.totalWaves > 0
350
+ ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}`
351
+ : "0/0";
352
+
353
+ // Build lane cards from monitor state (if available) or current lanes
354
+ const laneCards: OrchLaneCardData[] = [];
355
+
356
+ // TP-170: Detect stale monitor data from prior waves.
357
+ // When wave N+1 starts, batchState.currentLanes is updated to wave N+1's
358
+ // lanes, but monitorState may still hold wave N's data until the first
359
+ // poll of wave N+1's monitor. Detect this mismatch by checking whether
360
+ // the monitor's lane numbers match the current allocation.
361
+ const monitorIsFresh =
362
+ monitorState &&
363
+ monitorState.lanes.length > 0 &&
364
+ // If no current allocation, monitor data is the best we have
365
+ // (covers terminal phases like completed/failed/stopped)
366
+ (batchState.currentLanes.length === 0 ||
367
+ // If allocated lanes exist, verify monitor lanes match them
368
+ monitorState.lanes.some((ml) =>
369
+ batchState.currentLanes.some((cl) => cl.laneNumber === ml.laneNumber),
370
+ ));
371
+
372
+ // TP-170: Build a laneNumber โ†’ AllocatedLane index for identity reconciliation.
373
+ // In workspace mode, the monitorโ€™s sessionName (e.g., "orch-henry-api-lane-1")
374
+ // may differ from the V2 registry agentId ("orch-henry-lane-3-worker").
375
+ // Cross-referencing with the current allocation ensures the displayed session
376
+ // name matches the authoritative laneSessionId for the current wave.
377
+ const allocatedByLaneNumber = new Map<number, { laneSessionId: string; laneId: string }>();
378
+ for (const cl of batchState.currentLanes) {
379
+ allocatedByLaneNumber.set(cl.laneNumber, { laneSessionId: cl.laneSessionId, laneId: cl.laneId });
380
+ }
381
+
382
+ if (monitorIsFresh && monitorState) {
383
+ // Sort lanes by laneNumber (deterministic)
384
+ const sortedLanes = [...monitorState.lanes].sort((a, b) => a.laneNumber - b.laneNumber);
385
+
386
+ for (const lane of sortedLanes) {
387
+ const snap = lane.currentTaskSnapshot;
388
+ const alloc = allocatedByLaneNumber.get(lane.laneNumber);
389
+
390
+ // TP-170: Reconcile task-level vs lane-level sessionAlive.
391
+ // resolveTaskMonitorState may derive sessionAlive from the lane
392
+ // snapshot file (snap.status === "running") while the lane-level
393
+ // sessionAlive comes from isV2AgentAlive (PID check). When the
394
+ // task snapshot says "running" but the lane session is confirmed
395
+ // dead, the task is effectively failed โ€” not still running.
396
+ let status: OrchLaneCardData["status"] = "idle";
397
+ if (lane.failedTasks.length > 0) {
398
+ status = "failed";
399
+ } else if (snap?.status === "stalled") {
400
+ status = "stalled";
401
+ } else if (snap?.status === "running") {
402
+ // TP-170: TOCTOU guard โ€” if lane session is dead but task snapshot
403
+ // still says "running", treat as failed instead of showing
404
+ // "session dead" in the card. This prevents the false positive
405
+ // where the lane snapshot file lags behind the PID liveness check.
406
+ status = lane.sessionAlive ? "running" : "failed";
407
+ } else if (
408
+ lane.completedTasks.length > 0 &&
409
+ lane.remainingTasks.length === 0 &&
410
+ !lane.currentTaskId
411
+ ) {
412
+ status = "succeeded";
413
+ }
414
+
415
+ laneCards.push({
416
+ laneNumber: lane.laneNumber,
417
+ laneId: alloc?.laneId || lane.laneId,
418
+ // TP-170: Prefer the allocationโ€™s laneSessionId (current-wave authority)
419
+ // over the monitorโ€™s sessionName which may use a stale or workspace-local
420
+ // name that doesnโ€™t match the V2 registry.
421
+ sessionName: alloc?.laneSessionId || lane.sessionName,
422
+ sessionAlive: lane.sessionAlive,
423
+ currentTaskId: lane.currentTaskId,
424
+ currentStepName: snap?.currentStepName || null,
425
+ totalChecked: snap?.totalChecked || 0,
426
+ totalItems: snap?.totalItems || 0,
427
+ completedTasks: lane.completedTasks.length,
428
+ totalLaneTasks:
429
+ lane.completedTasks.length +
430
+ lane.failedTasks.length +
431
+ lane.remainingTasks.length +
432
+ (lane.currentTaskId ? 1 : 0),
433
+ status,
434
+ stallReason: snap?.stallReason || null,
435
+ });
436
+ }
437
+ } else if (batchState.currentLanes.length > 0) {
438
+ // No fresh monitor data โ€” show lanes from allocation.
439
+ // This covers both initial startup (monitor hasn't polled yet)
440
+ // and wave transitions (monitor data is stale from prior wave).
441
+ const sortedLanes = [...batchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber);
442
+ for (const lane of sortedLanes) {
443
+ laneCards.push({
444
+ laneNumber: lane.laneNumber,
445
+ laneId: lane.laneId,
446
+ sessionName: lane.laneSessionId,
447
+ sessionAlive: true, // assumed alive during allocation
448
+ currentTaskId: lane.tasks.length > 0 ? lane.tasks[0].taskId : null,
449
+ currentStepName: null,
450
+ totalChecked: 0,
451
+ totalItems: 0,
452
+ completedTasks: 0,
453
+ totalLaneTasks: lane.tasks.length,
454
+ status: "running",
455
+ stallReason: null,
456
+ });
457
+ }
458
+ }
459
+
460
+ // Determine attach hint
461
+ let attachHint = "";
462
+ const aliveLane = laneCards.find((l) => l.sessionAlive && l.status === "running");
463
+ if (aliveLane) {
464
+ attachHint = `Use /orch-sessions to inspect active lane sessions (${aliveLane.sessionName})`;
465
+ } else if (laneCards.length > 0) {
466
+ attachHint = "Use /orch-sessions for active lane session list";
467
+ }
468
+
469
+ // Determine failure policy if batch was stopped
470
+ let failurePolicy: string | null = null;
471
+ if (batchState.phase === "stopped" && batchState.waveResults.length > 0) {
472
+ const lastWave = batchState.waveResults[batchState.waveResults.length - 1];
473
+ if (lastWave.stoppedEarly && lastWave.policyApplied) {
474
+ failurePolicy = lastWave.policyApplied;
475
+ }
476
+ }
477
+
478
+ return {
479
+ phase: batchState.phase,
480
+ batchId: batchState.batchId,
481
+ orchBranch: batchState.orchBranch || batchState.baseBranch || "",
482
+ waveProgress,
483
+ elapsed,
484
+ summary,
485
+ laneCards,
486
+ attachHint,
487
+ errors: batchState.errors,
488
+ failurePolicy,
489
+ };
490
+ }
491
+
492
+ // โ”€โ”€ Lane Card Rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
493
+
494
+ /**
495
+ * Render a single lane card for the dashboard.
496
+ *
497
+ * Follows the task-runner `renderStepCard` pattern:
498
+ * bordered box with lane info, status icon, task progress.
499
+ *
500
+ * @param card - Lane card data from view-model
501
+ * @param colWidth - Available width for the card (including borders)
502
+ * @param theme - Pi theme object for color styling
503
+ * @returns Array of styled string lines (one per card row)
504
+ */
505
+ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: any): string[] {
506
+ const w = colWidth - 2; // inner width (excluding โ”‚ borders)
507
+ const trunc = (s: string, max: number) => (s.length > max ? s.slice(0, max - 3) + "..." : s);
508
+
509
+ // Status icon and color
510
+ const statusIcon =
511
+ card.status === "succeeded"
512
+ ? "โœ“"
513
+ : card.status === "running"
514
+ ? "โ—"
515
+ : card.status === "failed"
516
+ ? "โœ—"
517
+ : card.status === "stalled"
518
+ ? "โš "
519
+ : "โ—‹";
520
+ const statusColor =
521
+ card.status === "succeeded"
522
+ ? "success"
523
+ : card.status === "running"
524
+ ? "accent"
525
+ : card.status === "failed"
526
+ ? "error"
527
+ : card.status === "stalled"
528
+ ? "warning"
529
+ : "dim";
530
+
531
+ // Line 1: Session name (e.g., "โŽกorch-lane-1โŽค")
532
+ const sessionLabel = `โŽก${card.sessionName}โŽค`;
533
+ const sessionStr = theme.fg("accent", theme.bold(trunc(sessionLabel, w)));
534
+ const sessionVis = Math.min(sessionLabel.length, w);
535
+
536
+ // Line 2: Status + current task
537
+ const taskInfo = card.currentTaskId
538
+ ? `${statusIcon} ${card.currentTaskId}`
539
+ : card.status === "succeeded"
540
+ ? `${statusIcon} done`
541
+ : card.status === "failed"
542
+ ? `${statusIcon} failed`
543
+ : `${statusIcon} idle`;
544
+ const taskStr = theme.fg(statusColor, trunc(taskInfo, w));
545
+ const taskVis = Math.min(taskInfo.length, w);
546
+
547
+ // Line 3: Step progress
548
+ let stepInfo = "";
549
+ if (card.currentStepName) {
550
+ stepInfo = trunc(card.currentStepName, w - 2);
551
+ } else if (card.currentTaskId && card.totalItems === 0) {
552
+ // TP-170: Distinguish startup-grace (no STATUS.md yet) from
553
+ // genuine stale data. During startup, the lane is alive but
554
+ // hasnโ€™t written STATUS.md yet โ€” show "starting..." instead of
555
+ // the misleading "waiting for data..." which implies a problem.
556
+ stepInfo = card.sessionAlive ? "starting..." : "no status data";
557
+ } else if (!card.currentTaskId && card.status !== "idle") {
558
+ stepInfo = `${card.completedTasks}/${card.totalLaneTasks} tasks`;
559
+ }
560
+ const stepStr = theme.fg("muted", trunc(stepInfo, w));
561
+ const stepVis = Math.min(stepInfo.length, w);
562
+
563
+ // Line 4: Checkbox progress or stall reason
564
+ let extraInfo = "";
565
+ let extraColor = "dim";
566
+ if (card.stallReason) {
567
+ extraInfo = `โš  ${trunc(card.stallReason, w - 4)}`;
568
+ extraColor = "warning";
569
+ } else if (card.totalItems > 0) {
570
+ extraInfo = `${card.totalChecked}/${card.totalItems} โœ“`;
571
+ extraColor = card.totalChecked === card.totalItems ? "success" : "muted";
572
+ } else if (!card.sessionAlive && card.status === "running") {
573
+ // TP-170: With the TOCTOU guard in buildDashboardViewModel, a lane
574
+ // with a dead session and task snapshot "running" now gets status
575
+ // "failed" instead. This branch guards any remaining edge cases
576
+ // (e.g., allocation-fallback lane assumed alive but actually dead).
577
+ extraInfo = "session ended";
578
+ extraColor = "warning";
579
+ }
580
+ const extraStr = theme.fg(extraColor, trunc(extraInfo, w));
581
+ const extraVis = Math.min(extraInfo.length, w);
582
+
583
+ // Build bordered card
584
+ const top = "โ”Œ" + "โ”€".repeat(w) + "โ”";
585
+ const bot = "โ””" + "โ”€".repeat(w) + "โ”˜";
586
+ const border = (content: string, vis: number) =>
587
+ theme.fg("dim", "โ”‚") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "โ”‚");
588
+
589
+ return [
590
+ theme.fg("dim", top),
591
+ border(" " + sessionStr, 1 + sessionVis),
592
+ border(" " + taskStr, 1 + taskVis),
593
+ border(" " + stepStr, 1 + stepVis),
594
+ border(extraInfo ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
595
+ theme.fg("dim", bot),
596
+ ];
597
+ }
598
+
599
+ // โ”€โ”€ Core Widget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
600
+
601
+ /**
602
+ * Create the widget registration callback for the orchestrator dashboard.
603
+ *
604
+ * This is the main entry point for the dashboard widget. It captures
605
+ * batchState and monitorState references and returns a widget that
606
+ * re-renders on each paint cycle using the latest state.
607
+ *
608
+ * @param getBatchState - Getter for current batch state
609
+ * @param getMonitorState - Getter for current monitor state (may be null)
610
+ * @param sessionPrefix - Session prefix for lane identification
611
+ */
612
+ export function createOrchWidget(
613
+ getBatchState: () => OrchBatchRuntimeState,
614
+ getMonitorState: () => MonitorState | null,
615
+ sessionPrefix: string,
616
+ ): (_tui: any, theme: any) => { render(width: number): string[]; invalidate(): void } {
617
+ return (_tui: any, theme: any) => {
618
+ return {
619
+ render(width: number): string[] {
620
+ const batchState = getBatchState();
621
+ const monitorState = getMonitorState();
622
+ const vm = buildDashboardViewModel(batchState, monitorState);
623
+
624
+ // โ”€โ”€ Idle state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
625
+ if (vm.phase === "idle") {
626
+ return [];
627
+ }
628
+
629
+ const lines: string[] = [""];
630
+
631
+ // โ”€โ”€ Phase-specific rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
632
+ const phaseIcon =
633
+ vm.phase === "launching"
634
+ ? "โ—Œ"
635
+ : vm.phase === "planning"
636
+ ? "โ—Œ"
637
+ : vm.phase === "executing"
638
+ ? "โ—"
639
+ : vm.phase === "merging"
640
+ ? "๐Ÿ”€"
641
+ : vm.phase === "paused"
642
+ ? "โธ"
643
+ : vm.phase === "stopped"
644
+ ? "โ›”"
645
+ : vm.phase === "completed"
646
+ ? "โœ“"
647
+ : vm.phase === "failed"
648
+ ? "โœ—"
649
+ : "โ—‹";
650
+ const phaseColor =
651
+ vm.phase === "executing"
652
+ ? "accent"
653
+ : vm.phase === "merging"
654
+ ? "accent"
655
+ : vm.phase === "completed"
656
+ ? "success"
657
+ : vm.phase === "failed" || vm.phase === "stopped"
658
+ ? "error"
659
+ : vm.phase === "paused"
660
+ ? "warning"
661
+ : "dim";
662
+
663
+ // Header: phase icon + batch ID + wave + elapsed
664
+ const header =
665
+ theme.fg(phaseColor, ` ${phaseIcon} `) +
666
+ theme.fg("accent", theme.bold(vm.batchId || "โ€”")) +
667
+ theme.fg("dim", " ") +
668
+ theme.fg("warning", `W${vm.waveProgress}`) +
669
+ theme.fg("dim", " ยท ") +
670
+ theme.fg("muted", vm.elapsed);
671
+ lines.push(truncateToWidth(header, width));
672
+
673
+ // โ”€โ”€ Planning state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
674
+ if (vm.phase === "planning") {
675
+ lines.push(truncateToWidth(theme.fg("dim", " โ—Œ Planning batch..."), width));
676
+ return lines;
677
+ }
678
+
679
+ // โ”€โ”€ Progress bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
680
+ const { completed, failed, total } = vm.summary;
681
+ const done = completed + failed;
682
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
683
+ const barWidth = Math.min(30, width - 20);
684
+ const filled = Math.round((pct / 100) * barWidth);
685
+ const progressBar =
686
+ theme.fg("dim", " ") +
687
+ theme.fg("warning", "[") +
688
+ theme.fg("success", "โ–ˆ".repeat(filled)) +
689
+ theme.fg("dim", "โ–‘".repeat(Math.max(0, barWidth - filled))) +
690
+ theme.fg("warning", "]") +
691
+ theme.fg("dim", " ") +
692
+ theme.fg("accent", `${done}/${total}`) +
693
+ theme.fg("dim", ` (${pct}%)`);
694
+ lines.push(truncateToWidth(progressBar, width));
695
+
696
+ // โ”€โ”€ Summary counts line โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
697
+ const countParts: string[] = [];
698
+ if (vm.summary.completed > 0) countParts.push(theme.fg("success", `${vm.summary.completed} โœ“`));
699
+ if (vm.summary.running > 0)
700
+ countParts.push(theme.fg("accent", `${vm.summary.running} running`));
701
+ if (vm.summary.queued > 0) countParts.push(theme.fg("dim", `${vm.summary.queued} queued`));
702
+ if (vm.summary.failed > 0) countParts.push(theme.fg("error", `${vm.summary.failed} โœ—`));
703
+ if (vm.summary.blocked > 0)
704
+ countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`));
705
+ if (vm.summary.stalled > 0)
706
+ countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`));
707
+ if (countParts.length > 0) {
708
+ lines.push(truncateToWidth(" " + countParts.join(theme.fg("dim", " ยท ")), width));
709
+ }
710
+ lines.push("");
711
+
712
+ // โ”€โ”€ Lane cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
713
+ if (
714
+ vm.laneCards.length > 0 &&
715
+ (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")
716
+ ) {
717
+ const arrowWidth = 3;
718
+ const minCardWidth = 18;
719
+ const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
720
+ const cols = Math.min(vm.laneCards.length, maxCols);
721
+ const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
722
+
723
+ for (let rowStart = 0; rowStart < vm.laneCards.length; rowStart += cols) {
724
+ const rowCards = vm.laneCards.slice(rowStart, rowStart + cols);
725
+ const rendered = rowCards.map((c) => renderLaneCard(c, colWidth, theme));
726
+
727
+ if (rendered.length > 0) {
728
+ const cardHeight = rendered[0].length;
729
+ for (let line = 0; line < cardHeight; line++) {
730
+ let row = rendered[0][line];
731
+ for (let c = 1; c < rendered.length; c++) {
732
+ row += " "; // spacer between cards
733
+ row += rendered[c][line];
734
+ }
735
+ lines.push(truncateToWidth(row, width));
736
+ }
737
+ }
738
+ }
739
+ }
740
+
741
+ // โ”€โ”€ Terminal states (completed/failed/stopped) โ”€โ”€
742
+ if (vm.phase === "completed") {
743
+ lines.push(truncateToWidth(theme.fg("success", " โœ… Batch complete"), width));
744
+ } else if (vm.phase === "failed") {
745
+ lines.push(truncateToWidth(theme.fg("error", " โŒ Batch failed"), width));
746
+ for (const err of vm.errors.slice(0, 3)) {
747
+ lines.push(truncateToWidth(theme.fg("error", ` ${err.slice(0, 80)}`), width));
748
+ }
749
+ } else if (vm.phase === "stopped") {
750
+ lines.push(
751
+ truncateToWidth(theme.fg("error", ` โ›” Stopped by ${vm.failurePolicy || "policy"}`), width),
752
+ );
753
+ } else if (vm.phase === "merging") {
754
+ lines.push("");
755
+ lines.push(
756
+ truncateToWidth(
757
+ theme.fg("accent", ` ๐Ÿ”€ Merging lane branches into ${vm.orchBranch || "orch branch"}...`),
758
+ width,
759
+ ),
760
+ );
761
+ } else if (vm.phase === "paused") {
762
+ lines.push("");
763
+ lines.push(
764
+ truncateToWidth(
765
+ theme.fg("warning", " โธ Batch paused โ€” lanes will stop after current tasks"),
766
+ width,
767
+ ),
768
+ );
769
+ }
770
+
771
+ // โ”€โ”€ Footer: attach hint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
772
+ if (
773
+ vm.attachHint &&
774
+ (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")
775
+ ) {
776
+ lines.push("");
777
+ lines.push(truncateToWidth(theme.fg("dim", ` ๐Ÿ’ก ${vm.attachHint}`), width));
778
+ }
779
+
780
+ return lines;
781
+ },
782
+ invalidate() {},
783
+ };
784
+ };
785
+ }