pi-harness-runtime 0.2.0 → 0.3.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/README.md +133 -158
- package/harness/agent-handoff.ts +189 -0
- package/harness/blackboard.ts +291 -0
- package/harness/context-window-manager.ts +210 -0
- package/harness/e2e/playwright-runner.ts +252 -0
- package/harness/e2e/test-engine.ts +402 -0
- package/harness/job-state-machine.ts +363 -0
- package/harness/loop-runtime.ts +337 -0
- package/harness/master-planner.ts +327 -0
- package/harness/project-detector/detector.ts +328 -0
- package/harness/repair-engine.ts +340 -0
- package/harness/task-graph.ts +336 -0
- package/index.ts +294 -2
- package/package.json +6 -3
- package/packages/checkpoint/README.md +3 -0
- package/packages/checkpoint/src/checkpoint-manager.ts +38 -0
- package/packages/provider-router/src/provider-router.ts +42 -0
- package/packages/providers/README.md +3 -0
- package/packages/providers/adapters.ts +261 -0
- package/packages/quota-manager/README.md +3 -0
- package/packages/quota-manager/quota-manager.ts +328 -0
- package/packages/runtime/README.md +3 -0
- package/packages/scheduler/README.md +3 -0
- package/packages/scheduler/src/scheduler.ts +50 -0
- package/packages/shared-context/README.md +3 -0
- package/packages/shared-context/src/shared-context.ts +42 -0
- package/packages/tui/README.md +3 -0
- package/packages/types/src/runtime-types.ts +439 -0
- package/packages/worktree/README.md +3 -0
- package/packages/worktree/worktree.ts +293 -0
- package/skills/harness-runtime/SKILL.md +186 -72
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Blackboard — RFC-0011
|
|
3
|
+
*
|
|
4
|
+
* Durable file-based coordination so agents communicate without the human
|
|
5
|
+
* acting as message bus.
|
|
6
|
+
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* harness/blackboard/
|
|
9
|
+
* status.json
|
|
10
|
+
* next_action.json
|
|
11
|
+
* tasks.json
|
|
12
|
+
* agent_registry.json
|
|
13
|
+
* locks/
|
|
14
|
+
* reports/
|
|
15
|
+
* context/
|
|
16
|
+
* events.jsonl
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
BlackboardRecord,
|
|
21
|
+
NextAction,
|
|
22
|
+
AgentRegistry,
|
|
23
|
+
AgentReport,
|
|
24
|
+
LockInfo,
|
|
25
|
+
RuntimeEvent,
|
|
26
|
+
TaskGraph,
|
|
27
|
+
} from "../packages/types/src/runtime-types.ts";
|
|
28
|
+
import { writeJson, readJson, appendJsonl, ensureUsageDir } from "../cli.ts";
|
|
29
|
+
// @ts-expect-error - Bun has built-in Node.js types
|
|
30
|
+
import { join, dirname } from "node:path";
|
|
31
|
+
|
|
32
|
+
export class SharedBlackboard {
|
|
33
|
+
private readonly jobDir: string;
|
|
34
|
+
private record: BlackboardRecord | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(jobId: string, rootDir: string) {
|
|
37
|
+
this.jobDir = join(rootDir, "jobs", jobId, "blackboard");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Initialize a new blackboard for a job
|
|
42
|
+
*/
|
|
43
|
+
init(jobId: string, taskGraph: TaskGraph): void {
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
this.record = {
|
|
46
|
+
jobId,
|
|
47
|
+
status: "created",
|
|
48
|
+
nextAction: undefined,
|
|
49
|
+
tasks: taskGraph,
|
|
50
|
+
agentRegistry: { agents: {} },
|
|
51
|
+
reports: {},
|
|
52
|
+
locks: {},
|
|
53
|
+
updatedAt: now,
|
|
54
|
+
};
|
|
55
|
+
this.save();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load blackboard from disk
|
|
60
|
+
*/
|
|
61
|
+
load(): BlackboardRecord | null {
|
|
62
|
+
const path = join(this.jobDir, "status.json");
|
|
63
|
+
this.record = readJson(path) as BlackboardRecord | null;
|
|
64
|
+
return this.record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Save blackboard to disk
|
|
69
|
+
*/
|
|
70
|
+
save(): void {
|
|
71
|
+
if (!this.record) return;
|
|
72
|
+
this.record.updatedAt = new Date().toISOString();
|
|
73
|
+
ensureUsageDir();
|
|
74
|
+
const path = join(this.jobDir, "status.json");
|
|
75
|
+
writeJson(path, this.record);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Update job status
|
|
80
|
+
*/
|
|
81
|
+
updateStatus(status: BlackboardRecord["status"]): void {
|
|
82
|
+
if (!this.record) return;
|
|
83
|
+
this.record.status = status;
|
|
84
|
+
this.save();
|
|
85
|
+
this.appendEvent("StatusUpdated", { status });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Set the next action for agents to pick up
|
|
90
|
+
*/
|
|
91
|
+
setNextAction(action: NextAction): void {
|
|
92
|
+
if (!this.record) return;
|
|
93
|
+
this.record.nextAction = action;
|
|
94
|
+
this.save();
|
|
95
|
+
this.appendEvent("NextActionUpdated", {
|
|
96
|
+
taskId: action.taskId,
|
|
97
|
+
agentId: action.agentId,
|
|
98
|
+
priority: action.priority,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear the next action (after agent picks it up)
|
|
104
|
+
*/
|
|
105
|
+
clearNextAction(): void {
|
|
106
|
+
if (!this.record) return;
|
|
107
|
+
this.record.nextAction = undefined;
|
|
108
|
+
this.save();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register an agent
|
|
113
|
+
*/
|
|
114
|
+
registerAgent(
|
|
115
|
+
agentId: string,
|
|
116
|
+
name: string,
|
|
117
|
+
provider: string,
|
|
118
|
+
model?: string,
|
|
119
|
+
): void {
|
|
120
|
+
if (!this.record) return;
|
|
121
|
+
this.record.agentRegistry.agents[agentId] = {
|
|
122
|
+
id: agentId,
|
|
123
|
+
name,
|
|
124
|
+
provider,
|
|
125
|
+
model,
|
|
126
|
+
status: "idle",
|
|
127
|
+
startedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
this.save();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Update agent status
|
|
134
|
+
*/
|
|
135
|
+
updateAgentStatus(
|
|
136
|
+
agentId: string,
|
|
137
|
+
status: AgentRegistry["agents"][string]["status"],
|
|
138
|
+
currentTaskId?: string,
|
|
139
|
+
): void {
|
|
140
|
+
if (!this.record) return;
|
|
141
|
+
const agent = this.record.agentRegistry.agents[agentId];
|
|
142
|
+
if (!agent) return;
|
|
143
|
+
agent.status = status;
|
|
144
|
+
agent.currentTaskId = currentTaskId;
|
|
145
|
+
agent.lastHeartbeat = new Date().toISOString();
|
|
146
|
+
this.save();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Unregister an agent
|
|
151
|
+
*/
|
|
152
|
+
unregisterAgent(agentId: string): void {
|
|
153
|
+
if (!this.record) return;
|
|
154
|
+
delete this.record.agentRegistry.agents[agentId];
|
|
155
|
+
this.save();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write an agent report
|
|
160
|
+
*/
|
|
161
|
+
writeReport(report: AgentReport): void {
|
|
162
|
+
if (!this.record) return;
|
|
163
|
+
this.record.reports[report.agentId] = report;
|
|
164
|
+
this.save();
|
|
165
|
+
this.appendEvent("AgentReportWritten", {
|
|
166
|
+
agentId: report.agentId,
|
|
167
|
+
taskId: report.taskId,
|
|
168
|
+
status: report.status,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Acquire a lock on a task
|
|
174
|
+
*/
|
|
175
|
+
acquireLock(taskId: string, agentId: string): boolean {
|
|
176
|
+
if (!this.record) return false;
|
|
177
|
+
if (this.record.locks[taskId]) {
|
|
178
|
+
return false; // Already locked
|
|
179
|
+
}
|
|
180
|
+
this.record.locks[taskId] = {
|
|
181
|
+
taskId,
|
|
182
|
+
agentId,
|
|
183
|
+
acquiredAt: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
this.save();
|
|
186
|
+
this.appendEvent("LockAcquired", { taskId, agentId });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Release a lock on a task
|
|
192
|
+
*/
|
|
193
|
+
releaseLock(taskId: string, agentId: string): boolean {
|
|
194
|
+
if (!this.record) return false;
|
|
195
|
+
const lock = this.record.locks[taskId];
|
|
196
|
+
if (!lock || lock.agentId !== agentId) {
|
|
197
|
+
return false; // Not locked by this agent
|
|
198
|
+
}
|
|
199
|
+
delete this.record.locks[taskId];
|
|
200
|
+
this.save();
|
|
201
|
+
this.appendEvent("LockReleased", { taskId, agentId });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check if a task is locked
|
|
207
|
+
*/
|
|
208
|
+
isLocked(taskId: string): boolean {
|
|
209
|
+
return !!this.record?.locks[taskId];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get lock info for a task
|
|
214
|
+
*/
|
|
215
|
+
getLock(taskId: string): LockInfo | null {
|
|
216
|
+
return this.record?.locks[taskId] ?? null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get the current record
|
|
221
|
+
*/
|
|
222
|
+
getRecord(): BlackboardRecord | null {
|
|
223
|
+
return this.record;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get active agents
|
|
228
|
+
*/
|
|
229
|
+
getActiveAgents(): AgentRegistry["agents"][string][] {
|
|
230
|
+
if (!this.record) return [];
|
|
231
|
+
return Object.values(this.record.agentRegistry.agents);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check for stale agents (no heartbeat in N minutes)
|
|
236
|
+
*/
|
|
237
|
+
getStaleAgents(
|
|
238
|
+
maxAgeMinutes: number = 10,
|
|
239
|
+
): AgentRegistry["agents"][string][] {
|
|
240
|
+
if (!this.record) return [];
|
|
241
|
+
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
242
|
+
return Object.values(this.record.agentRegistry.agents).filter((a) => {
|
|
243
|
+
if (!a.lastHeartbeat) return false;
|
|
244
|
+
return Date.parse(a.lastHeartbeat) < cutoff;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Append an event to the event log
|
|
250
|
+
*/
|
|
251
|
+
private appendEvent(type: string, data?: Record<string, unknown>): void {
|
|
252
|
+
if (!this.record) return;
|
|
253
|
+
const event: RuntimeEvent = {
|
|
254
|
+
ts: new Date().toISOString(),
|
|
255
|
+
jobId: this.record.jobId,
|
|
256
|
+
type,
|
|
257
|
+
message: `Blackboard event: ${type}`,
|
|
258
|
+
data,
|
|
259
|
+
};
|
|
260
|
+
const path = join(this.jobDir, "events.jsonl");
|
|
261
|
+
ensureUsageDir();
|
|
262
|
+
appendJsonl(path, event);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Export full blackboard state
|
|
267
|
+
*/
|
|
268
|
+
export(): string {
|
|
269
|
+
return JSON.stringify(this.record, null, 2);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get blackboard directory path
|
|
274
|
+
*/
|
|
275
|
+
getPath(): string {
|
|
276
|
+
return this.jobDir;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create and initialize a blackboard for a job
|
|
282
|
+
*/
|
|
283
|
+
export function createBlackboard(
|
|
284
|
+
jobId: string,
|
|
285
|
+
rootDir: string,
|
|
286
|
+
taskGraph: TaskGraph,
|
|
287
|
+
): SharedBlackboard {
|
|
288
|
+
const blackboard = new SharedBlackboard(jobId, rootDir);
|
|
289
|
+
blackboard.init(jobId, taskGraph);
|
|
290
|
+
return blackboard;
|
|
291
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Window Manager — RFC-0010
|
|
3
|
+
*
|
|
4
|
+
* Manages context window usage across providers.
|
|
5
|
+
* Tracks utilization and suggests strategies when approaching limits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ContextWindowStats,
|
|
10
|
+
ContextWindowConfig,
|
|
11
|
+
ProviderMessage,
|
|
12
|
+
} from "../packages/types/src/runtime-types.ts";
|
|
13
|
+
|
|
14
|
+
export interface ContextWindowUpdate {
|
|
15
|
+
provider: string;
|
|
16
|
+
model: string;
|
|
17
|
+
maxTokens: number;
|
|
18
|
+
usedTokens: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ContextWindowManager {
|
|
22
|
+
private stats: Map<string, ContextWindowStats> = new Map();
|
|
23
|
+
private configs: Map<string, ContextWindowConfig> = new Map();
|
|
24
|
+
|
|
25
|
+
constructor(defaultConfigs?: Record<string, ContextWindowConfig>) {
|
|
26
|
+
// Initialize with defaults
|
|
27
|
+
const defaults: Record<string, ContextWindowConfig> = {
|
|
28
|
+
minimax: {
|
|
29
|
+
warningThreshold: 0.8,
|
|
30
|
+
criticalThreshold: 0.95,
|
|
31
|
+
strategy: "truncate",
|
|
32
|
+
},
|
|
33
|
+
anthropic: {
|
|
34
|
+
warningThreshold: 0.85,
|
|
35
|
+
criticalThreshold: 0.97,
|
|
36
|
+
strategy: "truncate",
|
|
37
|
+
},
|
|
38
|
+
openai: {
|
|
39
|
+
warningThreshold: 0.8,
|
|
40
|
+
criticalThreshold: 0.95,
|
|
41
|
+
strategy: "truncate",
|
|
42
|
+
},
|
|
43
|
+
...defaultConfigs,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const [provider, config] of Object.entries(defaults)) {
|
|
47
|
+
this.configs.set(provider, config);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Update context window stats after a request
|
|
53
|
+
*/
|
|
54
|
+
updateStats(update: ContextWindowUpdate): ContextWindowStats {
|
|
55
|
+
const { provider, model, maxTokens, usedTokens } = update;
|
|
56
|
+
const stats: ContextWindowStats = {
|
|
57
|
+
provider,
|
|
58
|
+
model,
|
|
59
|
+
maxTokens,
|
|
60
|
+
usedTokens,
|
|
61
|
+
availableTokens: maxTokens - usedTokens,
|
|
62
|
+
utilizationPct: usedTokens / maxTokens,
|
|
63
|
+
};
|
|
64
|
+
this.stats.set(`${provider}:${model}`, stats);
|
|
65
|
+
return stats;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get stats for a provider/model
|
|
70
|
+
*/
|
|
71
|
+
getStats(provider: string, model: string): ContextWindowStats | null {
|
|
72
|
+
return this.stats.get(`${provider}:${model}`) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all stats
|
|
77
|
+
*/
|
|
78
|
+
getAllStats(): ContextWindowStats[] {
|
|
79
|
+
return Array.from(this.stats.values());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check utilization status
|
|
84
|
+
*/
|
|
85
|
+
getUtilizationStatus(
|
|
86
|
+
provider: string,
|
|
87
|
+
model: string,
|
|
88
|
+
): "ok" | "warning" | "critical" | "unknown" {
|
|
89
|
+
const stats = this.getStats(provider, model);
|
|
90
|
+
if (!stats) return "unknown";
|
|
91
|
+
|
|
92
|
+
const config = this.configs.get(provider);
|
|
93
|
+
if (!config) return "ok";
|
|
94
|
+
|
|
95
|
+
const pct = stats.utilizationPct;
|
|
96
|
+
if (pct >= config.criticalThreshold) return "critical";
|
|
97
|
+
if (pct >= config.warningThreshold) return "warning";
|
|
98
|
+
return "ok";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Estimate remaining capacity
|
|
103
|
+
*/
|
|
104
|
+
estimateRemainingRequests(
|
|
105
|
+
provider: string,
|
|
106
|
+
model: string,
|
|
107
|
+
avgTokensPerRequest: number,
|
|
108
|
+
): number | null {
|
|
109
|
+
const stats = this.getStats(provider, model);
|
|
110
|
+
if (!stats) return null;
|
|
111
|
+
return Math.floor(stats.availableTokens / avgTokensPerRequest);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Prepare messages for a request (truncation/summarization strategy)
|
|
116
|
+
*/
|
|
117
|
+
prepareMessages(
|
|
118
|
+
messages: ProviderMessage[],
|
|
119
|
+
maxTokens: number,
|
|
120
|
+
strategy: "truncate" | "summarize" | "split" = "truncate",
|
|
121
|
+
): ProviderMessage[] {
|
|
122
|
+
const estimateTokens = (text: string) => Math.ceil(text.length / 4);
|
|
123
|
+
let totalTokens = messages.reduce(
|
|
124
|
+
(sum, m) => sum + estimateTokens(m.content),
|
|
125
|
+
0,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (totalTokens <= maxTokens) {
|
|
129
|
+
return messages;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
switch (strategy) {
|
|
133
|
+
case "truncate": {
|
|
134
|
+
// Remove oldest messages first
|
|
135
|
+
const truncated = [...messages];
|
|
136
|
+
while (totalTokens > maxTokens && truncated.length > 1) {
|
|
137
|
+
const removed = truncated.shift();
|
|
138
|
+
if (removed) {
|
|
139
|
+
totalTokens -= estimateTokens(removed.content);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return truncated;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "summarize":
|
|
146
|
+
// In a real implementation, this would use a summarization LLM
|
|
147
|
+
// For now, fall back to truncate
|
|
148
|
+
return this.prepareMessages(messages, maxTokens, "truncate");
|
|
149
|
+
|
|
150
|
+
case "split":
|
|
151
|
+
// Split into multiple requests if possible
|
|
152
|
+
// Return first portion
|
|
153
|
+
return this.prepareMessages(messages, maxTokens, "truncate");
|
|
154
|
+
|
|
155
|
+
default:
|
|
156
|
+
return messages;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Set config for a provider
|
|
162
|
+
*/
|
|
163
|
+
setConfig(provider: string, config: ContextWindowConfig): void {
|
|
164
|
+
this.configs.set(provider, config);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get config for a provider
|
|
169
|
+
*/
|
|
170
|
+
getConfig(provider: string): ContextWindowConfig | undefined {
|
|
171
|
+
return this.configs.get(provider);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Generate utilization report
|
|
176
|
+
*/
|
|
177
|
+
generateReport(): string {
|
|
178
|
+
const lines = ["Context Window Utilization Report", "=".repeat(40), ""];
|
|
179
|
+
|
|
180
|
+
for (const [key, stats] of this.stats.entries()) {
|
|
181
|
+
const config = this.configs.get(stats.provider);
|
|
182
|
+
const bar = this.renderBar(stats.utilizationPct);
|
|
183
|
+
const status = this.getUtilizationStatus(stats.provider, stats.model);
|
|
184
|
+
|
|
185
|
+
lines.push(`${stats.provider}/${stats.model}`);
|
|
186
|
+
lines.push(` ${bar} ${(stats.utilizationPct * 100).toFixed(1)}%`);
|
|
187
|
+
lines.push(
|
|
188
|
+
` Used: ${stats.usedTokens.toLocaleString()} / ${stats.maxTokens.toLocaleString()} tokens`,
|
|
189
|
+
);
|
|
190
|
+
lines.push(
|
|
191
|
+
` Available: ${stats.availableTokens.toLocaleString()} tokens`,
|
|
192
|
+
);
|
|
193
|
+
lines.push(` Status: ${status.toUpperCase()}`);
|
|
194
|
+
if (config) {
|
|
195
|
+
lines.push(
|
|
196
|
+
` Thresholds: warning=${(config.warningThreshold * 100).toFixed(0)}%, critical=${(config.criticalThreshold * 100).toFixed(0)}%`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
lines.push("");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private renderBar(pct: number, width: number = 20): string {
|
|
206
|
+
const filled = Math.round(pct * width);
|
|
207
|
+
const empty = width - filled;
|
|
208
|
+
return "[" + "█".repeat(filled) + "░".repeat(empty) + "]";
|
|
209
|
+
}
|
|
210
|
+
}
|