harness-async 0.1.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/dist/dashboard/assets/index-TGNGdtwt.js +246 -0
  4. package/dist/dashboard/assets/index-f4TpA4iP.css +1 -0
  5. package/dist/dashboard/index.html +13 -0
  6. package/dist/src/adapters/claude-adapter.js +52 -0
  7. package/dist/src/adapters/codex-adapter.js +55 -0
  8. package/dist/src/adapters/index.js +14 -0
  9. package/dist/src/adapters/shared.js +74 -0
  10. package/dist/src/cli/commands/daemon.js +116 -0
  11. package/dist/src/cli/commands/doctor.js +50 -0
  12. package/dist/src/cli/commands/hook.js +188 -0
  13. package/dist/src/cli/commands/init.js +22 -0
  14. package/dist/src/cli/commands/run.js +129 -0
  15. package/dist/src/cli/commands/schedule.js +105 -0
  16. package/dist/src/cli/commands/task.js +188 -0
  17. package/dist/src/cli/index.js +23 -0
  18. package/dist/src/cli/utils/notify.js +32 -0
  19. package/dist/src/cli/utils/output.js +94 -0
  20. package/dist/src/core/daemon.js +375 -0
  21. package/dist/src/core/dag.js +80 -0
  22. package/dist/src/core/event-log.js +34 -0
  23. package/dist/src/core/lock.js +25 -0
  24. package/dist/src/core/run-manager.js +265 -0
  25. package/dist/src/core/run-orchestrator.js +193 -0
  26. package/dist/src/core/scheduler.js +106 -0
  27. package/dist/src/core/sessions.js +48 -0
  28. package/dist/src/core/store.js +225 -0
  29. package/dist/src/core/task-manager.js +375 -0
  30. package/dist/src/core/tmux.js +51 -0
  31. package/dist/src/daemon.js +35 -0
  32. package/dist/src/dashboard/routes.js +107 -0
  33. package/dist/src/dashboard/server.js +142 -0
  34. package/dist/src/dashboard/ws.js +75 -0
  35. package/dist/src/types/adapter.js +30 -0
  36. package/dist/src/types/index.js +87 -0
  37. package/package.json +65 -0
@@ -0,0 +1,129 @@
1
+ import { getRun, listRuns } from '../../core/run-manager.js';
2
+ import { collectRunOutput, getRunAttachCommand, startTaskRun, stopTaskRun, syncRunState, } from '../../core/run-orchestrator.js';
3
+ import { renderRunTable } from '../utils/output.js';
4
+ export function registerRunCommand(program) {
5
+ const run = program.command('run').description('Manage task execution runs');
6
+ run
7
+ .command('start')
8
+ .description('Start a task run with a target tool')
9
+ .argument('[taskId]', 'Task ID')
10
+ .option('--tool <tool>', 'Tool name, for example claude or codex')
11
+ .option('--auto', 'Pick the next runnable task automatically')
12
+ .action(async (taskId, options) => {
13
+ try {
14
+ if (!options.tool) {
15
+ throw new Error('--tool is required');
16
+ }
17
+ const created = await startTaskRun({
18
+ cwd: process.cwd(),
19
+ homeDir: process.env.HA_HOME,
20
+ taskId: taskId ? Number(taskId) : undefined,
21
+ tool: options.tool,
22
+ auto: options.auto,
23
+ });
24
+ console.log(`Started run ${created.id} in tmux session ${created.tmuxSession}`);
25
+ }
26
+ catch (error) {
27
+ process.exitCode = 1;
28
+ console.error(error.message);
29
+ }
30
+ });
31
+ run
32
+ .command('list')
33
+ .description('Run list on task executions')
34
+ .option('--task-id <id>', 'Filter runs by task ID')
35
+ .action(async (options) => {
36
+ try {
37
+ const runs = await listRuns({
38
+ cwd: process.cwd(),
39
+ homeDir: process.env.HA_HOME,
40
+ scope: 'project',
41
+ taskId: options.taskId ? Number(options.taskId) : undefined,
42
+ });
43
+ const synced = await Promise.all(runs.map(async (entry) => entry.status === 'running'
44
+ ? syncRunState({
45
+ cwd: process.cwd(),
46
+ homeDir: process.env.HA_HOME,
47
+ }, entry.id)
48
+ : entry));
49
+ console.log(renderRunTable(synced));
50
+ }
51
+ catch (error) {
52
+ process.exitCode = 1;
53
+ console.error(error.message);
54
+ }
55
+ });
56
+ run
57
+ .command('show')
58
+ .description('Run show on task executions')
59
+ .argument('<runId>', 'Run ID')
60
+ .action(async (runId) => {
61
+ try {
62
+ const current = await syncRunState({
63
+ cwd: process.cwd(),
64
+ homeDir: process.env.HA_HOME,
65
+ }, runId);
66
+ console.log(JSON.stringify(current, null, 2));
67
+ }
68
+ catch (error) {
69
+ process.exitCode = 1;
70
+ console.error(error.message);
71
+ }
72
+ });
73
+ run
74
+ .command('logs')
75
+ .description('Run logs on task executions')
76
+ .argument('<runId>', 'Run ID')
77
+ .action(async (runId) => {
78
+ try {
79
+ const logs = await collectRunOutput({
80
+ cwd: process.cwd(),
81
+ homeDir: process.env.HA_HOME,
82
+ }, runId);
83
+ console.log(logs || '(empty)');
84
+ }
85
+ catch (error) {
86
+ process.exitCode = 1;
87
+ console.error(error.message);
88
+ }
89
+ });
90
+ run
91
+ .command('attach')
92
+ .description('Run attach on task executions')
93
+ .argument('<runId>', 'Run ID')
94
+ .action(async (runId) => {
95
+ try {
96
+ console.log(await getRunAttachCommand({
97
+ cwd: process.cwd(),
98
+ homeDir: process.env.HA_HOME,
99
+ }, runId));
100
+ }
101
+ catch (error) {
102
+ process.exitCode = 1;
103
+ console.error(error.message);
104
+ }
105
+ });
106
+ run
107
+ .command('stop')
108
+ .description('Run stop on task executions')
109
+ .argument('<runId>', 'Run ID')
110
+ .action(async (runId) => {
111
+ try {
112
+ await stopTaskRun({
113
+ cwd: process.cwd(),
114
+ homeDir: process.env.HA_HOME,
115
+ }, runId);
116
+ const stopped = await getRun({
117
+ cwd: process.cwd(),
118
+ homeDir: process.env.HA_HOME,
119
+ runId,
120
+ scope: 'all',
121
+ });
122
+ console.log(`Stopped run ${stopped.id}`);
123
+ }
124
+ catch (error) {
125
+ process.exitCode = 1;
126
+ console.error(error.message);
127
+ }
128
+ });
129
+ }
@@ -0,0 +1,105 @@
1
+ import { addSchedule, listSchedules, removeSchedule, triggerSchedule } from '../../core/scheduler.js';
2
+ import { readConfig, resolveStoreDir } from '../../core/store.js';
3
+ import { renderTable } from '../utils/output.js';
4
+ export function registerScheduleCommand(program) {
5
+ const schedule = program
6
+ .command('schedule')
7
+ .description('Manage scheduled task creation and execution');
8
+ schedule
9
+ .command('add')
10
+ .description('Add a new schedule entry')
11
+ .requiredOption('--name <name>', 'Schedule name')
12
+ .requiredOption('--cron <expr>', 'Cron expression')
13
+ .option('--command <command>', 'Command to execute')
14
+ .option('--auto', 'Run the next unlocked L1 task automatically')
15
+ .option('--tool <tool>', 'Tool used with --auto, defaults to config.defaultAgent')
16
+ .action(async (options) => {
17
+ try {
18
+ const command = await resolveScheduleCommand(options);
19
+ const created = await addSchedule({
20
+ cwd: process.cwd(),
21
+ homeDir: process.env.HA_HOME,
22
+ name: options.name,
23
+ cron: options.cron,
24
+ command,
25
+ });
26
+ console.log(`Added schedule ${created.name}`);
27
+ }
28
+ catch (error) {
29
+ process.exitCode = 1;
30
+ console.error(error.message);
31
+ }
32
+ });
33
+ schedule
34
+ .command('list')
35
+ .description('List configured schedule entries')
36
+ .action(async () => {
37
+ try {
38
+ const schedules = await listSchedules({
39
+ cwd: process.cwd(),
40
+ homeDir: process.env.HA_HOME,
41
+ });
42
+ console.log(renderTable(schedules.map((entry) => ({
43
+ name: entry.name,
44
+ status: entry.enabled ? 'enabled' : 'disabled',
45
+ detail: `${entry.cron} | ${entry.command}`,
46
+ }))));
47
+ }
48
+ catch (error) {
49
+ process.exitCode = 1;
50
+ console.error(error.message);
51
+ }
52
+ });
53
+ schedule
54
+ .command('remove')
55
+ .description('Remove a schedule entry')
56
+ .argument('<name>', 'Schedule name')
57
+ .action(async (name) => {
58
+ try {
59
+ await removeSchedule({
60
+ cwd: process.cwd(),
61
+ homeDir: process.env.HA_HOME,
62
+ name,
63
+ });
64
+ console.log(`Removed schedule ${name}`);
65
+ }
66
+ catch (error) {
67
+ process.exitCode = 1;
68
+ console.error(error.message);
69
+ }
70
+ });
71
+ schedule
72
+ .command('trigger')
73
+ .description('Trigger a schedule entry immediately')
74
+ .argument('<name>', 'Schedule name')
75
+ .action(async (name) => {
76
+ try {
77
+ await triggerSchedule({
78
+ cwd: process.cwd(),
79
+ homeDir: process.env.HA_HOME,
80
+ name,
81
+ });
82
+ console.log(`Triggered schedule ${name}`);
83
+ }
84
+ catch (error) {
85
+ process.exitCode = 1;
86
+ console.error(error.message);
87
+ }
88
+ });
89
+ }
90
+ async function resolveScheduleCommand(options) {
91
+ if (options.auto) {
92
+ const storeDir = resolveStoreDir({
93
+ cwd: process.cwd(),
94
+ homeDir: process.env.HA_HOME,
95
+ scope: 'global',
96
+ });
97
+ const config = await readConfig(storeDir);
98
+ const tool = options.tool ?? config.defaultAgent;
99
+ return `ha run start --auto --tool ${tool}`;
100
+ }
101
+ if (!options.command) {
102
+ throw new Error('--command is required unless --auto is set');
103
+ }
104
+ return options.command;
105
+ }
@@ -0,0 +1,188 @@
1
+ import { createTask, failTask, getTaskGraph, listTasks, showTask, updateTaskStatus, } from '../../core/task-manager.js';
2
+ import { renderTaskGraphAscii, renderTaskGraphMermaid, renderTaskTable, } from '../utils/output.js';
3
+ import { taskLevelSchema, taskStatusSchema } from '../../types/index.js';
4
+ export function registerTaskCommand(program) {
5
+ const task = program.command('task').description('Create and inspect tasks');
6
+ task
7
+ .command('create')
8
+ .description('Create a new task from inline text or a markdown file')
9
+ .argument('[title]', 'Task title')
10
+ .option('--level <level>', 'Task level: L1, L2, or L3')
11
+ .option('--deps <ids>', 'Comma-separated dependency task IDs')
12
+ .option('--assignee <assignee>', 'Task assignee: claude, codex, human, or auto')
13
+ .option('--global', 'Create the task in ~/.ha instead of the project store')
14
+ .option('--file <file>', 'Import task content from a markdown file')
15
+ .addHelpText('after', '\nExamples:\n $ ha task create "为认证模块编写单元测试" --level L1 --deps 2 --assignee claude\n $ ha task create --global "每日 lint 检查" --level L1\n $ ha task create --file ./my-task.md')
16
+ .action(async (title, options) => {
17
+ try {
18
+ const created = await createTask({
19
+ cwd: process.cwd(),
20
+ homeDir: process.env.HA_HOME,
21
+ title,
22
+ level: taskLevelSchema.parse(options.level ?? 'L1'),
23
+ assignee: options.assignee,
24
+ deps: parseDeps(options.deps),
25
+ scope: options.global ? 'global' : 'project',
26
+ file: options.file,
27
+ });
28
+ console.log(`Created task #${created.task.id}: ${created.task.title} [${created.task.level}]`);
29
+ }
30
+ catch (error) {
31
+ process.exitCode = 1;
32
+ console.error(error.message);
33
+ }
34
+ });
35
+ task
36
+ .command('list')
37
+ .description('List tasks from project, global, or aggregated stores')
38
+ .option('--global', 'Read from ~/.ha')
39
+ .option('--all', 'Read from all configured stores')
40
+ .option('--level <level>', 'Filter by level')
41
+ .option('--status <status>', 'Filter by status')
42
+ .action(async (options) => {
43
+ try {
44
+ const tasks = await listTasks({
45
+ cwd: process.cwd(),
46
+ homeDir: process.env.HA_HOME,
47
+ scope: options.all ? 'all' : options.global ? 'global' : 'project',
48
+ level: options.level ? taskLevelSchema.parse(options.level) : undefined,
49
+ status: options.status ? taskStatusSchema.parse(options.status) : undefined,
50
+ });
51
+ console.log(renderTaskTable(tasks.map((item) => ({
52
+ id: item.id,
53
+ level: item.level,
54
+ status: item.status,
55
+ title: item.title,
56
+ updated: item.updated,
57
+ }))));
58
+ }
59
+ catch (error) {
60
+ process.exitCode = 1;
61
+ console.error(error.message);
62
+ }
63
+ });
64
+ task
65
+ .command('show')
66
+ .description('Show the full markdown task document')
67
+ .argument('<id>', 'Task ID')
68
+ .action(async (id) => {
69
+ try {
70
+ const raw = await showTask({
71
+ cwd: process.cwd(),
72
+ homeDir: process.env.HA_HOME,
73
+ id: Number(id),
74
+ });
75
+ process.stdout.write(raw);
76
+ }
77
+ catch (error) {
78
+ process.exitCode = 1;
79
+ console.error(error.message);
80
+ }
81
+ });
82
+ task
83
+ .command('update')
84
+ .description('Update task metadata or status')
85
+ .argument('<id>', 'Task ID')
86
+ .option('--status <status>', 'New task status')
87
+ .action(async (id, options) => {
88
+ try {
89
+ if (!options.status) {
90
+ throw new Error('--status is required');
91
+ }
92
+ const updated = await updateTaskStatus({
93
+ cwd: process.cwd(),
94
+ homeDir: process.env.HA_HOME,
95
+ id: Number(id),
96
+ status: taskStatusSchema.parse(options.status),
97
+ actor: 'system',
98
+ });
99
+ console.log(`Updated task #${updated.id} -> ${updated.status}`);
100
+ }
101
+ catch (error) {
102
+ process.exitCode = 1;
103
+ console.error(error.message);
104
+ }
105
+ });
106
+ task
107
+ .command('complete')
108
+ .description('Mark a task as completed')
109
+ .argument('<id>', 'Task ID')
110
+ .action(async (id) => {
111
+ try {
112
+ const updated = await updateTaskStatus({
113
+ cwd: process.cwd(),
114
+ homeDir: process.env.HA_HOME,
115
+ id: Number(id),
116
+ status: 'completed',
117
+ actor: 'system',
118
+ });
119
+ console.log(`Completed task #${updated.id}`);
120
+ }
121
+ catch (error) {
122
+ process.exitCode = 1;
123
+ console.error(error.message);
124
+ }
125
+ });
126
+ task
127
+ .command('fail')
128
+ .description('Mark a task as failed')
129
+ .argument('<id>', 'Task ID')
130
+ .option('--reason <reason>', 'Failure reason')
131
+ .action(async (id, options) => {
132
+ try {
133
+ const updated = await failTask({
134
+ cwd: process.cwd(),
135
+ homeDir: process.env.HA_HOME,
136
+ id: Number(id),
137
+ actor: 'system',
138
+ reason: options.reason,
139
+ });
140
+ console.log(`Failed task #${updated.id}`);
141
+ }
142
+ catch (error) {
143
+ process.exitCode = 1;
144
+ console.error(error.message);
145
+ }
146
+ });
147
+ task
148
+ .command('graph')
149
+ .description('Render the task DAG')
150
+ .option('--format <format>', 'Output format, for example ascii or mermaid')
151
+ .option('--all', 'Aggregate all configured stores')
152
+ .action(async (options) => {
153
+ try {
154
+ const graph = await getTaskGraph({
155
+ cwd: process.cwd(),
156
+ homeDir: process.env.HA_HOME,
157
+ scope: options.all ? 'all' : 'project',
158
+ });
159
+ if (options.format === 'mermaid') {
160
+ console.log(renderTaskGraphMermaid(graph.tasks.map((task) => ({
161
+ id: task.id,
162
+ level: task.level,
163
+ title: task.title,
164
+ })), graph.edges));
165
+ return;
166
+ }
167
+ console.log(renderTaskGraphAscii(graph.tasks.map((task) => ({
168
+ id: task.id,
169
+ level: task.level,
170
+ status: task.status,
171
+ title: task.title,
172
+ })), graph.edges));
173
+ }
174
+ catch (error) {
175
+ process.exitCode = 1;
176
+ console.error(error.message);
177
+ }
178
+ });
179
+ }
180
+ function parseDeps(value) {
181
+ if (!value) {
182
+ return [];
183
+ }
184
+ return value
185
+ .split(',')
186
+ .map((part) => Number.parseInt(part.trim(), 10))
187
+ .filter((part) => Number.isFinite(part));
188
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { registerDaemonCommand } from './commands/daemon.js';
4
+ import { registerDoctorCommand } from './commands/doctor.js';
5
+ import { registerHookCommand } from './commands/hook.js';
6
+ import { registerInitCommand } from './commands/init.js';
7
+ import { registerRunCommand } from './commands/run.js';
8
+ import { registerScheduleCommand } from './commands/schedule.js';
9
+ import { registerTaskCommand } from './commands/task.js';
10
+ const program = new Command();
11
+ program
12
+ .name('ha')
13
+ .description('Agent-first task orchestration CLI')
14
+ .showHelpAfterError()
15
+ .showSuggestionAfterError();
16
+ registerInitCommand(program);
17
+ registerDoctorCommand(program);
18
+ registerHookCommand(program);
19
+ registerTaskCommand(program);
20
+ registerScheduleCommand(program);
21
+ registerDaemonCommand(program);
22
+ registerRunCommand(program);
23
+ await program.parseAsync(process.argv);
@@ -0,0 +1,32 @@
1
+ export function buildNotification(type, taskId, title) {
2
+ const taskLabel = `Task #${taskId} "${title}"`;
3
+ switch (type) {
4
+ case 'task.completed':
5
+ return { title: `${taskLabel} 已完成`, message: '任务已完成', open: 'http://localhost:3777' };
6
+ case 'task.failed':
7
+ return { title: `${taskLabel} 执行失败`, message: '任务执行失败', open: 'http://localhost:3777' };
8
+ case 'task.paused':
9
+ return { title: `${taskLabel} 会话已暂停`, message: '请检查 agent 会话状态', open: 'http://localhost:3777' };
10
+ case 'task.waiting-review':
11
+ return { title: `${taskLabel} 需要你的 review`, message: '任务等待 review', open: 'http://localhost:3777' };
12
+ case 'task.dependency_unlocked':
13
+ return { title: `Task #${taskId} 已解锁`, message: title, open: 'http://localhost:3777' };
14
+ default:
15
+ return null;
16
+ }
17
+ }
18
+ export async function notifyTaskEvent(event, adapter = defaultNotifyAdapter) {
19
+ const payload = buildNotification(event.type, event.taskId, event.title);
20
+ if (!payload) {
21
+ return;
22
+ }
23
+ await adapter(payload);
24
+ }
25
+ async function defaultNotifyAdapter(payload) {
26
+ const notifierModule = (await import('node-notifier'));
27
+ const notify = notifierModule.default?.notify ?? notifierModule.notify;
28
+ if (!notify) {
29
+ throw new Error('node-notifier notify function is unavailable');
30
+ }
31
+ notify(payload);
32
+ }
@@ -0,0 +1,94 @@
1
+ import chalk from 'chalk';
2
+ export function renderComingSoon(commandName) {
3
+ return `${commandName} is coming soon`;
4
+ }
5
+ export function renderTable(rows) {
6
+ const nameWidth = Math.max('Check'.length, ...rows.map((row) => row.name.length));
7
+ const statusWidth = Math.max('Status'.length, ...rows.map((row) => row.status.length));
8
+ const header = [
9
+ 'Check'.padEnd(nameWidth),
10
+ 'Status'.padEnd(statusWidth),
11
+ 'Detail',
12
+ ].join(' | ');
13
+ const divider = [
14
+ '-'.repeat(nameWidth),
15
+ '-'.repeat(statusWidth),
16
+ '------',
17
+ ].join('-|-');
18
+ const lines = rows.map((row) => [row.name.padEnd(nameWidth), row.status.padEnd(statusWidth), row.detail].join(' | '));
19
+ return [header, divider, ...lines].join('\n');
20
+ }
21
+ export function renderTaskTable(tasks) {
22
+ const headers = ['ID', 'Level', 'Status', 'Title', 'Updated'];
23
+ const rows = tasks.map((task) => [
24
+ String(task.id),
25
+ colorizeLevel(task.level),
26
+ task.status,
27
+ task.title,
28
+ task.updated,
29
+ ]);
30
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => stripAnsi(row[index] ?? '').length)));
31
+ const header = headers
32
+ .map((value, index) => value.padEnd(widths[index] ?? value.length))
33
+ .join(' | ');
34
+ const divider = widths.map((width) => '-'.repeat(width)).join('-|-');
35
+ const lines = rows.map((row) => row
36
+ .map((value, index) => padAnsi(value, widths[index] ?? stripAnsi(value).length))
37
+ .join(' | '));
38
+ return [header, divider, ...lines].join('\n');
39
+ }
40
+ export function renderTaskGraphAscii(tasks, edges) {
41
+ const lines = [];
42
+ for (const task of tasks) {
43
+ lines.push(`[${task.id}] [${task.level}] [${task.status}] ${task.title}`);
44
+ for (const edge of edges.filter((entry) => entry.to === task.id)) {
45
+ lines.push(` └─ depends on [${edge.from}]`);
46
+ }
47
+ }
48
+ return lines.join('\n');
49
+ }
50
+ export function renderTaskGraphMermaid(tasks, edges) {
51
+ const nodes = tasks.map((task) => `${task.id}["[${task.level}] ${task.title}"]`);
52
+ const connectors = edges.map((edge) => `${edge.from}["${nodeLabel(tasks, edge.from)}"] --> ${edge.to}["${nodeLabel(tasks, edge.to)}"]`);
53
+ return ['graph TD', ...nodes, ...connectors].join('\n');
54
+ }
55
+ export function renderRunTable(runs) {
56
+ const headers = ['Run', 'Task', 'Tool', 'Status', 'Started', 'Session'];
57
+ const rows = runs.map((run) => [
58
+ run.id,
59
+ String(run.taskId),
60
+ run.tool,
61
+ run.status,
62
+ run.startedAt,
63
+ run.tmuxSession,
64
+ ]);
65
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
66
+ return [
67
+ headers.map((header, index) => header.padEnd(widths[index] ?? header.length)).join(' | '),
68
+ widths.map((width) => '-'.repeat(width)).join('-|-'),
69
+ ...rows.map((row) => row
70
+ .map((value, index) => value.padEnd(widths[index] ?? value.length))
71
+ .join(' | ')),
72
+ ].join('\n');
73
+ }
74
+ function colorizeLevel(level) {
75
+ if (level === 'L1') {
76
+ return chalk.green(level);
77
+ }
78
+ if (level === 'L2') {
79
+ return chalk.yellow(level);
80
+ }
81
+ return chalk.red(level);
82
+ }
83
+ function nodeLabel(tasks, id) {
84
+ const task = tasks.find((entry) => entry.id === id);
85
+ return task ? `[${task.level}] ${task.title}` : String(id);
86
+ }
87
+ function stripAnsi(value) {
88
+ const escape = String.fromCharCode(27);
89
+ return value.replace(new RegExp(`${escape}\\[[0-9;]*m`, 'g'), '');
90
+ }
91
+ function padAnsi(value, width) {
92
+ const visibleLength = stripAnsi(value).length;
93
+ return `${value}${' '.repeat(Math.max(0, width - visibleLength))}`;
94
+ }